Repository: permissionlesstech/bitchat Branch: main Commit: b6e45e3228e7 Files: 254 Total size: 3.1 MB Directory structure: gitextract_fcb18bnp/ ├── .gitattributes ├── .github/ │ └── workflows/ │ ├── fetch_georelays.yml │ └── swift-tests.yml ├── .gitignore ├── BRING_THE_NOISE.md ├── Configs/ │ ├── Debug.xcconfig │ ├── Local.xcconfig.example │ └── Release.xcconfig ├── Justfile ├── LICENSE ├── PRIVACY_POLICY.md ├── Package.resolved ├── Package.swift ├── README.md ├── WHITEPAPER.md ├── bitchat/ │ ├── Assets.xcassets/ │ │ ├── AccentColor.colorset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ ├── AppIconDebug.appiconset/ │ │ │ └── Contents.json │ │ └── Contents.json │ ├── BitchatApp.swift │ ├── Features/ │ │ ├── media/ │ │ │ └── ImageUtils.swift │ │ └── voice/ │ │ ├── VoiceNotePlaybackController.swift │ │ ├── VoiceRecorder.swift │ │ └── Waveform.swift │ ├── Identity/ │ │ ├── IdentityModels.swift │ │ └── SecureIdentityStateManager.swift │ ├── Info.plist │ ├── LaunchScreen.storyboard │ ├── Localizable.xcstrings │ ├── Models/ │ │ ├── BitchatMessage.swift │ │ ├── BitchatPacket.swift │ │ ├── BitchatPeer.swift │ │ ├── CommandInfo.swift │ │ ├── MessagePadding.swift │ │ ├── NoisePayload.swift │ │ ├── PeerID.swift │ │ ├── ReadReceipt.swift │ │ └── RequestSyncPacket.swift │ ├── Noise/ │ │ ├── NoiseProtocol.swift │ │ ├── NoiseRateLimiter.swift │ │ ├── NoiseSecurityConstants.swift │ │ ├── NoiseSecurityError.swift │ │ ├── NoiseSecurityValidator.swift │ │ ├── NoiseSession.swift │ │ ├── NoiseSessionError.swift │ │ ├── NoiseSessionManager.swift │ │ ├── NoiseSessionState.swift │ │ └── SecureNoiseSession.swift │ ├── Nostr/ │ │ ├── Bech32.swift │ │ ├── GeoRelayDirectory.swift │ │ ├── NostrEmbeddedBitChat.swift │ │ ├── NostrIdentity.swift │ │ ├── NostrIdentityBridge.swift │ │ ├── NostrProtocol.swift │ │ ├── NostrRelayManager.swift │ │ └── XChaCha20Poly1305Compat.swift │ ├── Protocols/ │ │ ├── BinaryEncodingUtils.swift │ │ ├── BinaryProtocol.swift │ │ ├── BitchatFilePacket.swift │ │ ├── BitchatProtocol.swift │ │ ├── Geohash.swift │ │ ├── LocationChannel.swift │ │ └── Packets.swift │ ├── Services/ │ │ ├── AutocompleteService.swift │ │ ├── BLE/ │ │ │ ├── BLEService.swift │ │ │ └── MimeType.swift │ │ ├── CommandProcessor.swift │ │ ├── FavoritesPersistenceService.swift │ │ ├── GeohashParticipantTracker.swift │ │ ├── GeohashPresenceService.swift │ │ ├── KeychainManager.swift │ │ ├── LocationNotesManager.swift │ │ ├── LocationStateManager.swift │ │ ├── MeshTopologyTracker.swift │ │ ├── MessageDeduplicationService.swift │ │ ├── MessageFormattingEngine.swift │ │ ├── MessageRouter.swift │ │ ├── NetworkActivationService.swift │ │ ├── NoiseEncryptionService.swift │ │ ├── NostrTransport.swift │ │ ├── NotificationService.swift │ │ ├── NotificationStreamAssembler.swift │ │ ├── PrivateChatManager.swift │ │ ├── RelayController.swift │ │ ├── TransferProgressManager.swift │ │ ├── Transport.swift │ │ ├── TransportConfig.swift │ │ ├── UnifiedPeerService.swift │ │ └── VerificationService.swift │ ├── Sync/ │ │ ├── GCSFilter.swift │ │ ├── GossipSyncManager.swift │ │ ├── PacketIdUtil.swift │ │ ├── RequestSyncManager.swift │ │ └── SyncTypeFlags.swift │ ├── Utils/ │ │ ├── Color+Peer.swift │ │ ├── CompressionUtil.swift │ │ ├── Data+SHA256.swift │ │ ├── FileTransferLimits.swift │ │ ├── Font+Bitchat.swift │ │ ├── InputValidator.swift │ │ ├── MessageDeduplicator.swift │ │ ├── PeerDisplayNameResolver.swift │ │ ├── String+DJB2.swift │ │ └── String+Nickname.swift │ ├── ViewModels/ │ │ ├── ChatViewModel.swift │ │ ├── Extensions/ │ │ │ ├── ChatViewModel+Nostr.swift │ │ │ ├── ChatViewModel+PrivateChat.swift │ │ │ ├── ChatViewModel+Tor.swift │ │ │ └── README.md │ │ ├── GeoChannelCoordinator.swift │ │ ├── MessageRateLimiter.swift │ │ ├── MinimalDistancePalette.swift │ │ ├── PublicMessagePipeline.swift │ │ └── PublicTimelineStore.swift │ ├── Views/ │ │ ├── AppInfoView.swift │ │ ├── Components/ │ │ │ ├── CommandSuggestionsView.swift │ │ │ ├── DeliveryStatusView.swift │ │ │ ├── PaymentChipView.swift │ │ │ └── TextMessageView.swift │ │ ├── ContentView.swift │ │ ├── FingerprintView.swift │ │ ├── GeohashPeopleList.swift │ │ ├── LocationChannelsSheet.swift │ │ ├── LocationNotesView.swift │ │ ├── Media/ │ │ │ ├── BlockRevealImageView.swift │ │ │ ├── VoiceNoteView.swift │ │ │ └── WaveformView.swift │ │ ├── MeshPeerList.swift │ │ ├── MessageTextHelpers.swift │ │ └── VerificationViews.swift │ ├── _PreviewHelpers/ │ │ ├── BitchatMessage+Preview.swift │ │ └── PreviewKeychainManager.swift │ ├── bitchat-macOS.entitlements │ └── bitchat.entitlements ├── bitchat.xcodeproj/ │ ├── project.pbxproj │ └── xcshareddata/ │ └── xcschemes/ │ ├── bitchat (iOS).xcscheme │ └── bitchat (macOS).xcscheme ├── bitchatShareExtension/ │ ├── Info.plist │ ├── Localization/ │ │ └── Localizable.xcstrings │ ├── ShareViewController.swift │ └── bitchatShareExtension.entitlements ├── bitchatTests/ │ ├── BLEServiceCoreTests.swift │ ├── BLEServiceTests.swift │ ├── BitchatPeerTests.swift │ ├── ChatViewModelDeliveryStatusTests.swift │ ├── ChatViewModelExtensionsTests.swift │ ├── ChatViewModelRefactoringTests.swift │ ├── ChatViewModelTests.swift │ ├── ChatViewModelTorTests.swift │ ├── CommandProcessorTests.swift │ ├── EndToEnd/ │ │ ├── PrivateChatE2ETests.swift │ │ └── PublicChatE2ETests.swift │ ├── Features/ │ │ └── ImageUtilsTests.swift │ ├── FontBitchatTests.swift │ ├── Fragmentation/ │ │ └── FragmentationTests.swift │ ├── GCSFilterTests.swift │ ├── GeohashBookmarksStoreTests.swift │ ├── GeohashParticipantTrackerTests.swift │ ├── GeohashPresenceTests.swift │ ├── GossipSyncManagerTests.swift │ ├── Info.plist │ ├── InputValidatorTests.swift │ ├── Integration/ │ │ ├── IntegrationTests.swift │ │ └── TestNetworkHelper.swift │ ├── KeychainErrorHandlingTests.swift │ ├── Localization/ │ │ └── PrimaryLocalizationKeys.json │ ├── LocationChannelsTests.swift │ ├── LocationNotesManagerTests.swift │ ├── MessageDeduplicationServiceTests.swift │ ├── MessageFormattingEngineTests.swift │ ├── MimeTypeTests.swift │ ├── Mocks/ │ │ ├── MockBLEBus.swift │ │ ├── MockBLEService.swift │ │ ├── MockIdentityManager.swift │ │ ├── MockKeychain.swift │ │ └── MockTransport.swift │ ├── Noise/ │ │ ├── NoiseCoverageTests.swift │ │ ├── NoiseProtocolTests.swift │ │ ├── NoiseRateLimiterTests.swift │ │ └── NoiseTestVectors.json │ ├── Nostr/ │ │ └── GeoRelayDirectoryTests.swift │ ├── NostrProtocolTests.swift │ ├── NotificationBlockingTests.swift │ ├── NotificationStreamAssemblerTests.swift │ ├── PreviewKeychainManagerTests.swift │ ├── Protocol/ │ │ ├── BinaryProtocolPaddingTests.swift │ │ └── BinaryProtocolTests.swift │ ├── ProtocolContractTests.swift │ ├── Protocols/ │ │ ├── BinaryEncodingUtilsTests.swift │ │ ├── BitchatFilePacketTests.swift │ │ ├── LocationChannelTests.swift │ │ └── PacketsTests.swift │ ├── PublicMessagePipelineTests.swift │ ├── PublicTimelineStoreTests.swift │ ├── README.md │ ├── ReadReceiptTests.swift │ ├── Services/ │ │ ├── AutocompleteServiceTests.swift │ │ ├── FavoritesPersistenceServiceTests.swift │ │ ├── GeohashPresenceServiceTests.swift │ │ ├── LocationStateManagerTests.swift │ │ ├── MeshTopologyTrackerTests.swift │ │ ├── MessageRouterTests.swift │ │ ├── NetworkActivationServiceTests.swift │ │ ├── NoiseEncryptionServiceTests.swift │ │ ├── NostrRelayManagerTests.swift │ │ ├── NostrTransportTests.swift │ │ ├── NotificationServiceTests.swift │ │ ├── PrivateChatManagerTests.swift │ │ ├── RelayControllerTests.swift │ │ ├── SecureIdentityStateManagerTests.swift │ │ ├── TransferProgressManagerTests.swift │ │ ├── UnifiedPeerServiceTests.swift │ │ └── VerificationServiceTests.swift │ ├── SubscriptionRateLimitTests.swift │ ├── Sync/ │ │ └── RequestSyncManagerTests.swift │ ├── TestUtilities/ │ │ ├── TestConstants.swift │ │ └── TestHelpers.swift │ ├── Utils/ │ │ ├── HexStringTests.swift │ │ └── PeerIDTests.swift │ ├── ViewSmokeTests.swift │ └── XChaCha20Poly1305CompatTests.swift ├── docs/ │ ├── GeohashPresenceSpec.md │ ├── REQUEST_SYNC_MANAGER.md │ ├── SOURCE_ROUTING.md │ ├── TOR-INTEGRATION.md │ └── privacy-assessment.md ├── localPackages/ │ ├── Arti/ │ │ ├── .gitignore │ │ ├── Cargo.toml │ │ ├── Frameworks/ │ │ │ ├── arti.xcframework/ │ │ │ │ ├── Info.plist │ │ │ │ ├── ios-arm64/ │ │ │ │ │ ├── Headers/ │ │ │ │ │ │ └── arti.h │ │ │ │ │ └── libarti_bitchat.a │ │ │ │ ├── ios-arm64-simulator/ │ │ │ │ │ ├── Headers/ │ │ │ │ │ │ └── arti.h │ │ │ │ │ └── libarti_bitchat.a │ │ │ │ └── macos-arm64/ │ │ │ │ ├── Headers/ │ │ │ │ │ └── arti.h │ │ │ │ └── libarti_bitchat.a │ │ │ └── include/ │ │ │ └── arti.h │ │ ├── Package.swift │ │ ├── Sources/ │ │ │ ├── C/ │ │ │ │ ├── arti_shim.c │ │ │ │ └── include/ │ │ │ │ ├── arti.h │ │ │ │ └── module.modulemap │ │ │ ├── TorManager.swift │ │ │ ├── TorNotifications.swift │ │ │ └── TorURLSession.swift │ │ ├── arti-bitchat/ │ │ │ ├── Cargo.toml │ │ │ ├── cbindgen.toml │ │ │ └── src/ │ │ │ ├── lib.rs │ │ │ └── socks.rs │ │ └── build-ios.sh │ └── BitLogger/ │ ├── Package.swift │ ├── Sources/ │ │ ├── OSLog+Categories.swift │ │ ├── SecureLogger.swift │ │ └── String+Sanitization.swift │ └── Tests/ │ └── StringSanitizationTests.swift └── relays/ └── online_relays_gps.csv ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ # Prevent Github Languages stats skewing: # Binaries and assets **/*.xcframework/** linguist-vendored **/*.xcassets/** linguist-vendored # Generated files **/*.pbxproj linguist-generated **/*.storyboard linguist-generated Package.resolved linguist-generated # Downloaded CSVs relays/online_relays_gps.csv linguist-vendored # Docs **/*.md linguist-documentation # Configs Configs/*.xcconfig linguist-documentation **/*.plist linguist-documentation ================================================ FILE: .github/workflows/fetch_georelays.yml ================================================ name: Fetch GeoRelays Data on: schedule: - cron: '0 6 * * 0' workflow_dispatch: permissions: contents: write pull-requests: write jobs: update-relay-data: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 - name: Fetch GeoRelays run: | wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv mv nostr_relays.csv ./relays/online_relays_gps.csv - name: Check for changes id: git-check run: | git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT - name: Commit and push changes if: steps.git-check.outputs.changes == 'true' run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add relays/online_relays_gps.csv git commit -m "Automated update of relay data - $(date -u)" git push env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/swift-tests.yml ================================================ name: Build & Test on: push: branches: - main pull_request: branches: - main jobs: test: name: Run Swift Tests runs-on: macos-latest steps: - name: Checkout code uses: actions/checkout@v5 - name: Set up Swift uses: swift-actions/setup-swift@v2 - name: Build the package run: swift build - name: Run Tests run: swift test --parallel ================================================ FILE: .gitignore ================================================ # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore ## implementation plans plans/ ## AI CLAUDE.md AGENTS.md ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) *.xcscmblueprint *.xccheckout ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) build/ DerivedData/ .DerivedData/ *.moved-aside *.pbxuser !default.pbxuser *.mode1v3 !default.mode1v3 *.mode2v3 !default.mode2v3 *.perspectivev3 !default.perspectivev3 ## Gcc Patch /*.gcno ## macOS .DS_Store ## SPM .swiftpm .build/ ## CocoaPods Pods/ ## Carthage Carthage/Build/ ## fastlane fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output ## Code Injection iOSInjectionProject/ ## Xcode project *.xcodeproj/project.xcworkspace/ ## Xcode User settings xcuserdata/ ## Python __pycache__/ *.py[cod] *$py.class ## Temporary files *.tmp *.temp ## Cache .cache/ # Local build results .Result*/ .Result*.xcresult/ TestResult.xcresult/ *.xcresult/ build.log *.log # Local configs Local.xcconfig ================================================ FILE: BRING_THE_NOISE.md ================================================ # Bringing the Noise: Secure Communication in BitChat ## Overview BitChat implements the Noise Protocol Framework for end-to-end encryption, providing forward secrecy, identity hiding, and cryptographic authentication. This document details our Swift implementation and its integration with BitChat's decentralized mesh network. ## The Noise Protocol Framework ### Why Noise? The Noise Protocol Framework offers: - **Forward Secrecy**: Past messages remain secure even if keys are compromised - **Identity Hiding**: Peer identities are encrypted during handshake - **Simplicity**: Clean, auditable protocol with minimal complexity - **Performance**: Efficient for resource-constrained mobile devices - **Flexibility**: Supports various handshake patterns ### The XX Pattern BitChat uses the Noise XX pattern: ``` XX: -> e <- e, ee, s, es -> s, se ``` This three-message pattern provides: - Mutual authentication - Identity encryption (identities revealed only after initial key exchange) - Resistance to key-compromise impersonation ## Implementation Architecture ### Core Components #### NoiseEncryptionService The main service managing all Noise operations: ```swift final class NoiseEncryptionService { private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey private let sessionManager: NoiseSessionManager private let channelEncryption = NoiseChannelEncryption() } ``` #### NoiseSession Individual session state for each peer: ```swift final class NoiseSession { private var handshakeState: NoiseHandshakeState? private var sendCipher: NoiseCipherState? private var receiveCipher: NoiseCipherState? private let remoteStaticKey: Curve25519.KeyAgreement.PublicKey? } ``` #### NoiseSessionManager Thread-safe session management: ```swift final class NoiseSessionManager { private var sessions: [String: NoiseSession] = [:] private let sessionsQueue = DispatchQueue(label: "noise.sessions", attributes: .concurrent) } ``` ### Handshake Flow 1. **Initiator sends ephemeral key** ```swift let ephemeralKey = Curve25519.KeyAgreement.PrivateKey() let message = ephemeralKey.publicKey.rawRepresentation ``` 2. **Responder sends ephemeral + encrypted static** ```swift // Generate ephemeral, perform DH, encrypt static key let encryptedStatic = encrypt(staticKey, using: sharedSecret) ``` 3. **Initiator sends encrypted static** ```swift // Complete handshake, derive session keys let (sendKey, recvKey) = deriveSessionKeys(transcript) ``` ### Session Management Sessions are managed with automatic cleanup and rekey support: ```swift // Session lookup by peer ID func getSession(for peerID: String) -> NoiseSession? // Automatic session removal on disconnect func removeSession(for peerID: String) // Rekey detection func getSessionsNeedingRekey() -> [(String, Bool)] ``` ## Integration with BitChat ### Peer ID Rotation Noise sessions persist across peer ID rotations through fingerprint mapping: ```swift // Identity announcement after handshake struct NoiseIdentityAnnouncement { let peerID: String let publicKey: Data let nickname: String let previousPeerID: String? let signature: Data } ``` ### Message Encryption All messages are encrypted using established Noise sessions: ```swift // Encrypt message let encrypted = try noiseService.encrypt(messageData, for: peerID) // Decrypt message let decrypted = try noiseService.decrypt(encryptedData, from: peerID) ``` ## Security Properties ### Forward Secrecy - Ephemeral keys are generated for each handshake - Past sessions cannot be decrypted with current keys - Automatic rekey after 1 hour or 10,000 messages ### Authentication - Static keys provide long-term identity - Handshake ensures mutual authentication - MAC tags prevent message tampering ### Privacy - Peer identities encrypted during handshake - Metadata minimization through padding - No persistent session identifiers ## Implementation Details ### Cryptographic Primitives - **DH**: X25519 (Curve25519) - **Cipher**: ChaChaPoly (AEAD) - **Hash**: SHA-256 - **KDF**: HKDF-SHA256 ### Error Handling ```swift enum NoiseError: Error { case handshakeFailed case invalidMessage case sessionNotEstablished case decryptionFailed } ``` ## Performance Optimizations ### Connection Pooling - Reuse established sessions - Lazy handshake initiation - Session caching with TTL ### Message Batching - Combine small messages - Reduce encryption overhead - Optimize for BLE MTU ### Memory Management - Bounded session cache - Automatic cleanup of stale sessions - Efficient key rotation ## Protocol Version Negotiation BitChat implements protocol version negotiation to ensure compatibility between different client versions: ### Version Negotiation Flow 1. **Version Hello**: Upon connection, peers exchange supported protocol versions 2. **Version Agreement**: Peers agree on the highest common version 3. **Graceful Fallback**: Legacy peers without version negotiation assume protocol v1 ### Message Types ```swift case versionHello = 0x20 // Announce supported versions case versionAck = 0x21 // Acknowledge and agree on version ``` ### Backward Compatibility - Peers that don't send version negotiation messages are assumed to support v1 - Future protocol versions can be added to `ProtocolVersion.supportedVersions` - Incompatible peers receive a rejection message and disconnect gracefully ## Future Enhancements ### Post-Quantum Readiness - Hybrid handshake patterns - Kyber integration plans - Graceful algorithm migration ### Advanced Features - Multi-device support - Session backup/restore - Group messaging primitives ## Conclusion BitChat's Noise implementation provides encryption while maintaining the simplicity and performance required for a peer-to-peer messaging application. The protocol's elegant design ensures that people's communications remain private, authenticated, and forward-secure without sacrificing usability. ================================================ FILE: Configs/Debug.xcconfig ================================================ #include "Release.xcconfig" // Optional include of local configs #include? "Local.xcconfig" ================================================ FILE: Configs/Local.xcconfig.example ================================================ // Your Apple Developer Team ID - https://stackoverflow.com/a/18727947 DEVELOPMENT_TEAM = ABC123 // Unique bundle id to be able to register and run locally PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM) ================================================ FILE: Configs/Release.xcconfig ================================================ MARKETING_VERSION = 1.5.1 CURRENT_PROJECT_VERSION = 1 IPHONEOS_DEPLOYMENT_TARGET = 16.0 MACOSX_DEPLOYMENT_TARGET = 13.0 SWIFT_VERSION = 5.0 DEVELOPMENT_TEAM = L3N5LHJD5Y CODE_SIGN_STYLE = Automatic PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat ================================================ FILE: Justfile ================================================ # BitChat macOS Build Justfile # Handles temporary modifications needed to build and run on macOS # Default recipe - shows available commands default: @echo "BitChat macOS Build Commands:" @echo " just run - Build and run the macOS app" @echo " just build - Build the macOS app only" @echo " just clean - Clean build artifacts and restore original files" @echo " just check - Check prerequisites" @echo "" @echo "Original files are preserved - modifications are temporary for builds only" # Check prerequisites check: @echo "Checking prerequisites..." @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1) @xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1) @test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1) @xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1) @security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0) @echo "✅ All prerequisites met" # Backup original files backup: @echo "Backing up original project configuration..." @if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi @if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi # Restore original files restore: @echo "Restoring original project configuration..." @if [ -f project.yml.backup ]; then mv project.yml.backup project.yml; fi @# Restore iOS-specific files @if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi @# Use git to restore all modified files except Justfile @git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Could not restore some files with git" @# Remove any backup files @rm -f bitchat.xcodeproj/project.pbxproj.backup bitchat/Info.plist.backup 2>/dev/null || true # Apply macOS-specific modifications patch-for-macos: backup @echo "Temporarily hiding iOS-specific files for macOS build..." @# Move iOS-specific files out of the way temporarily @if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi # Build the macOS app build: #check generate @echo "Building BitChat for macOS..." @xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build # Run the macOS app run: build @echo "Launching BitChat..." @find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}" # Clean build artifacts and restore original files clean: restore @echo "Cleaning build artifacts..." @rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true @# Only remove the generated project if we have a backup, otherwise use git @if [ -f bitchat.xcodeproj/project.pbxproj.backup ]; then \ rm -rf bitchat.xcodeproj; \ else \ git checkout -- bitchat.xcodeproj/project.pbxproj 2>/dev/null || echo "⚠️ Could not restore project.pbxproj"; \ fi @rm -f project-macos.yml 2>/dev/null || true @echo "✅ Cleaned and restored original files" # Quick run without cleaning (for development) dev-run: check @echo "Quick development build..." @xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build @find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}" # Show app info info: @echo "BitChat - Decentralized Mesh Messaging" @echo "======================================" @echo "• Native macOS SwiftUI app" @echo "• Bluetooth LE mesh networking" @echo "• End-to-end encryption" @echo "• No internet required" @echo "• Works offline with nearby devices" @echo "" @echo "Requirements:" @echo "• macOS 13.0+ (Ventura)" @echo "• Bluetooth LE capable Mac" @echo "• Physical device (no simulator support)" @echo "" @echo "Usage:" @echo "• Set nickname and start chatting" @echo "• Use /join #channel for group chats" @echo "• Use /msg @user for private messages" @echo "• Triple-tap logo for emergency wipe" # Force clean everything (nuclear option) nuke: @echo "🧨 Nuclear clean - removing all build artifacts and backups..." @rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true @rm -rf bitchat.xcodeproj 2>/dev/null || true @rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true @rm -f bitchat/Info.plist.backup 2>/dev/null || true @# Restore iOS-specific files if they were moved @if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi @git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore" @echo "✅ Nuclear clean complete" ================================================ FILE: LICENSE ================================================ This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to ================================================ FILE: PRIVACY_POLICY.md ================================================ # bitchat Privacy Policy *Last updated: January 2025* ## Our Commitment bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy. ## Summary - **No personal data collection** - We don't collect names, emails, or phone numbers - **No servers** - Everything happens on your device and through peer-to-peer connections - **No tracking** - We have no analytics, telemetry, or user tracking - **Open source** - You can verify these claims by reading our code ## What Information bitchat Stores ### On Your Device Only 1. **Identity Key** - A cryptographic key generated on first launch - Stored locally in your device's secure storage - Allows you to maintain "favorite" relationships across app restarts - Never leaves your device 2. **Nickname** - The display name you choose (or auto-generated) - Stored only on your device - Shared with peers you communicate with 3. **Message History** (if enabled) - When room owners enable retention, messages are saved locally - Stored encrypted on your device - You can delete this at any time 4. **Favorite Peers** - Public keys of peers you mark as favorites - Stored only on your device - Allows you to recognize these peers in future sessions ### Temporary Session Data During each session, bitchat temporarily maintains: - Active peer connections (forgotten when app closes) - Routing information for message delivery - Cached messages for offline peers (12 hours max) ## What Information is Shared ### With Other bitchat Users When you use bitchat, nearby peers can see: - Your chosen nickname - Your ephemeral public key (changes each session) - Messages you send to public rooms or directly to them - Your approximate Bluetooth signal strength (for connection quality) ### With Room Members When you join a password-protected room: - Your messages are visible to others with the password - Your nickname appears in the member list - Room owners can see you've joined ## What We DON'T Do bitchat **never**: - Collects personal information - Tracks your location - Stores data on servers - Shares data with third parties - Uses analytics or telemetry - Creates user profiles - Requires registration ## Encryption All private messages use end-to-end encryption: - **X25519** for key exchange - **AES-256-GCM** for message encryption - **Ed25519** for digital signatures - **Argon2id** for password-protected rooms ## Your Rights You have complete control: - **Delete Everything**: Triple-tap the logo to instantly wipe all data - **Leave Anytime**: Close the app and your presence disappears - **No Account**: Nothing to delete from servers because there are none - **Portability**: Your data never leaves your device unless you export it ## Bluetooth & Permissions bitchat requires Bluetooth permission to function: - Used only for peer-to-peer communication - No location data is accessed or stored - Bluetooth is not used for tracking - You can revoke this permission at any time in system settings ## Children's Privacy bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. ## Data Retention - **Messages**: Deleted from memory when app closes (unless room retention is enabled) - **Identity Key**: Persists until you delete the app - **Favorites**: Persist until you remove them or delete the app - **Everything Else**: Exists only during active sessions ## Security Measures - All communication is encrypted - No data transmitted to servers (there are none) - Open source code for public audit - Regular security updates - Cryptographic signatures prevent tampering ## Changes to This Policy If we update this policy: - The "Last updated" date will change - The updated policy will be included in the app - No retroactive changes can affect data (since we don't collect any) ## Contact bitchat is an open source project. For privacy questions: - View our source code: [https://github.com/permissionlesstech/bitchat/tree/main](https://github.com/permissionlesstech/bitchat/tree/main) - Open an issue on GitHub - Join the discussion in public rooms ## Philosophy Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely. --- *This policy is released into the public domain under The Unlicense, just like bitchat itself.* ================================================ FILE: Package.resolved ================================================ { "pins" : [ { "identity" : "swift-secp256k1", "kind" : "remoteSourceControl", "location" : "https://github.com/21-DOT-DEV/swift-secp256k1", "state" : { "revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2", "version" : "0.21.1" } } ], "version" : 2 } ================================================ FILE: Package.swift ================================================ // swift-tools-version: 5.9 import PackageDescription let package = Package( name: "bitchat", defaultLocalization: "en", platforms: [ .iOS(.v16), .macOS(.v13) ], products: [ .executable( name: "bitchat", targets: ["bitchat"] ), ], dependencies:[ .package(path: "localPackages/Arti"), .package(path: "localPackages/BitLogger"), .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1") ], targets: [ .executableTarget( name: "bitchat", dependencies: [ .product(name: "P256K", package: "swift-secp256k1"), .product(name: "BitLogger", package: "BitLogger"), .product(name: "Tor", package: "Arti") ], path: "bitchat", exclude: [ "Info.plist", "Assets.xcassets", "bitchat.entitlements", "bitchat-macOS.entitlements", "LaunchScreen.storyboard", "ViewModels/Extensions/README.md" ], resources: [ .process("Localizable.xcstrings") ] ), .testTarget( name: "bitchatTests", dependencies: ["bitchat"], path: "bitchatTests", exclude: [ "Info.plist", "README.md" ], resources: [ .process("Localization"), .process("Noise") ] ) ] ) ================================================ FILE: README.md ================================================ icon_128x128@2x ## bitchat A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. It's the side-groupchat. [bitchat.free](http://bitchat.free) 📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622) ## License This project is released into the public domain. See the [LICENSE](LICENSE) file for details. ## Features - **Dual Transport Architecture**: Bluetooth mesh for offline + Nostr protocol for internet-based messaging - **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays - **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback) - **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE - **Privacy First**: No accounts, no phone numbers, no persistent identifiers - **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr - **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface - **Universal App**: Native support for iOS and macOS - **Emergency Wipe**: Triple-tap to instantly clear all data - **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking ## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat) BitChat uses a **hybrid messaging architecture** with two complementary transport layers: ### Bluetooth Mesh Network (Offline) - **Local Communication**: Direct peer-to-peer within Bluetooth range - **Multi-hop Relay**: Messages route through nearby devices (max 7 hops) - **No Internet Required**: Works completely offline in disaster scenarios - **Noise Protocol Encryption**: End-to-end encryption with forward secrecy - **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints - **Automatic Discovery**: Peer discovery and connection management - **Adaptive Power**: Battery-optimized duty cycling ### Nostr Protocol (Internet) - **Global Reach**: Connect with users worldwide via internet relays - **Location Channels**: Geographic chat rooms using geohash coordinates - **290+ Relay Network**: Distributed across the globe for reliability - **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy - **Ephemeral Keys**: Fresh cryptographic identity per geohash area ### Channel Types #### `mesh #bluetooth` - **Transport**: Bluetooth Low Energy mesh network - **Scope**: Local devices within multi-hop range - **Internet**: Not required - **Use Case**: Offline communication, protests, disasters, remote areas #### Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`) - **Transport**: Nostr protocol over internet - **Scope**: Geographic areas defined by geohash precision - `block` (7 chars): City block level - `neighborhood` (6 chars): District/neighborhood - `city` (5 chars): City level - `province` (4 chars): State/province - `region` (2 chars): Country/large region - **Internet**: Required (connects to Nostr relays) - **Use Case**: Location-based community chat, local events, regional discussions ### Direct Message Routing Private messages use **intelligent transport selection**: 1. **Bluetooth First** (preferred when available) - Direct connection with established Noise session - Fastest and most private option 2. **Nostr Fallback** (when Bluetooth unavailable) - Uses recipient's Nostr public key - NIP-17 gift-wrapping for privacy - Routes through global relay network 3. **Smart Queuing** (when neither available) - Messages queued until transport becomes available - Automatic delivery when connection established For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md). ## Setup ### Option 1: Using Xcode ```bash cd bitchat open bitchat.xcodeproj ``` To run on a device there're a few steps to prepare the code: - Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig` - Add your Developer Team ID into the newly created `Configs/Local.xcconfig` - Bundle ID would be set to `chat.bitchat.` (unless you set to something else) - Entitlements need to be updated manually (TODO: Automate): - Search and replace `group.chat.bitchat` with `group.` (e.g. `group.chat.bitchat.ABC123`) ### Option 2: Using `just` ```bash brew install just ``` Want to try this on macos: `just run` will set it up and run from source. Run `just clean` afterwards to restore things to original state for mobile app building and development. ## Localization - Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`. - Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`. - Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible. - Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates. ================================================ FILE: WHITEPAPER.md ================================================ # BitChat Protocol Whitepaper **Version 1.1** **Date: July 25, 2025** --- ## Abstract BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network. --- ## 1. Introduction In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE). The design goals of the BitChat Protocol are: * **Confidentiality:** All communication must be unreadable to third parties. * **Authentication:** Users must be able to verify the identity of their correspondents. * **Integrity:** Messages cannot be tampered with in transit. * **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys. * **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message. * **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments. This paper specifies the technical details of the protocol designed to meet these goals. --- ## 2. Protocol Stack The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility. ```mermaid graph TD A[Application Layer] --> B[Session Layer]; B --> C[Encryption Layer]; C --> D[Transport Layer]; subgraph "BitChat Application" A end subgraph "Message Framing & State" B end subgraph "Noise Protocol Framework" C end subgraph "BLE, Wi-Fi Direct, etc." D end style A fill:#cde4ff style B fill:#b5d8ff style C fill:#9ac2ff style D fill:#7eadff ``` * **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data. * **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format. * **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption. * **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol. --- ## 3. Identity and Key Management A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain. 1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions. 2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname. ### 3.1. Fingerprint A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code). `Fingerprint = SHA256(StaticPublicKey_Curve25519)` ### 3.2. Identity Management The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key. --- ## 4. The Social Trust Layer Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`. ### 4.1. Peer Verification While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations. ### 4.2. Favorites and Blocking To improve the user experience and provide control over interactions, the protocol supports: * **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently. * **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer. --- ## 5. The Noise Protocol Layer BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption. ### 5.1. Protocol Name The specific Noise protocol implemented is: **`Noise_XX_25519_ChaChaPoly_SHA256`** * **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment. * **`25519`:** The Diffie-Hellman function used is Curve25519. * **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305. * **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256. ### 5.2. The `XX` Handshake The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys. ```mermaid sequenceDiagram participant I as Initiator participant R as Responder Note over I, R: Pre-computation: h = SHA256(protocol_name) I->>R: -> e Note right of I: I generates ephemeral key `e_i`.
h = SHA256(h + e_i.pub) R->>I: <- e, ee, s, es Note left of R: R generates ephemeral key `e_r`.
h = SHA256(h + e_r.pub)
MixKey(DH(e_i, e_r))
R sends static key `s_r`, encrypted.
h = SHA256(h + ciphertext)
MixKey(DH(e_i, s_r)) I->>R: -> s, se Note right of I: I decrypts and verifies `s_r`.
I sends static key `s_i`, encrypted.
h = SHA256(h + ciphertext)
MixKey(DH(s_i, e_r)) Note over I, R: Handshake complete. Transport keys derived. ``` **Handshake Flow:** 1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder. 2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`). 3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`). Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding. ### 5.3. Session Management The `NoiseSessionManager` class manages all active Noise sessions. It handles: * Creating sessions for new peers. * Coordinating the handshake process to prevent race conditions. * Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`). * Periodically checking if sessions need to be re-keyed for enhanced security. --- ## 6. The BitChat Session and Application Protocol Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages. ### 6.1. Binary Packet Format (`BitchatPacket`) To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis. | Field | Size (bytes) | Description | |-----------------|--------------|---------------------------------------------------------------------------------------------------------| | **Header** | **13** | **Fixed-size header** | | Version | 1 | Protocol version (currently `1`). | | Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. | | TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. | | Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. | | Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). | | Payload Length | 2 | `UInt16` length of the payload field. | | **Variable** | **...** | **Variable-size fields** | | Sender ID | 8 | 8-byte truncated peer ID of the sender. | | Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. | | Payload | Variable | The actual content of the packet, as defined by the `Type` field. | | Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. | **Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers. ```mermaid --- config: theme: dark --- --- title: "BitchatPacket" --- packet +8: "Version" +8: "Type" +8: "TTL" +64: "Timestamp" +8: "Flags" +16: "Payload Length" +64: "Sender ID" +64: "Recipient ID (optional)" +48: "Payload (variable)" +64: "Signature (optional)" ``` _A representation of the sizes of the fields in `BitchatPacket`_ ### 6.2. Application Message Format (`BitchatMessage`) For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content. | Field | Size (bytes) | Description | |---------------------|--------------|--------------------------------------------------------------------------| | Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). | | Timestamp | 8 | `UInt64` millisecond timestamp of message creation. | | ID | 1 + len | `UUID` string for the message. | | Sender | 1 + len | Nickname of the sender. | | Content | 2 + len | The UTF-8 encoded message content. | | Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. | | Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. | ```mermaid --- config: theme: dark --- --- title: "BitchatMessage" --- packet +8: "Flags" +64: "Timestamp" +24: "ID (variable)" +32: "Sender (variable)" +32: "Content (variable)" +32: "Original Sender (variable) (optional)" +32: "Recipient Nickname (variable) (optional)" ``` _A representation of the sizes of the fields in `BitchatMessage`_ --- ## 7. Message Routing and Propagation BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery. ### 7.1. Direct Connection This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake. ### 7.2. Efficient Gossip with Bloom Filters To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs. The logic is as follows: 1. A peer receives a packet. 2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers. 3. If the packet is new, its ID is added to the Bloom filter. 4. The peer decrements the packet's Time-To-Live (TTL) field. 5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet. This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops. ### 7.3. Time-To-Live (TTL) Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh. ### 7.4. Private vs. Broadcast Messages The routing logic respects the confidentiality of private messages: * **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet. * **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network. ### 7.5. Message Reliability and Lifecycle To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery. * **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message. * **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen. * **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience. ### 7.6. Fragmentation Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol. * **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments. * **`fragmentContinue`:** These packets carry the intermediate chunks of the message data. * **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly. Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer. --- ## 8. Security Considerations * **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages. * **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer. * **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other. * **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks. * **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size. --- ## 9. Conclusion The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments. ================================================ FILE: bitchat/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.000", "green" : "1.000", "red" : "0.000" } }, "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: bitchat/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "icon_1024x1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { "filename" : "icon_16x16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { "filename" : "icon_32x32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { "filename" : "icon_128x128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { "filename" : "icon_256x256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { "filename" : "icon_512x512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: bitchat/Assets.xcassets/AppIconDebug.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "image-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: bitchat/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: bitchat/BitchatApp.swift ================================================ // // BitchatApp.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Tor import SwiftUI import UserNotifications @main struct BitchatApp: App { static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat" static let groupID = "group.\(bundleID)" @StateObject private var chatViewModel: ChatViewModel #if os(iOS) @Environment(\.scenePhase) var scenePhase @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate // Skip the very first .active-triggered Tor restart on cold launch @State private var didHandleInitialActive: Bool = false @State private var didEnterBackground: Bool = false #elseif os(macOS) @NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate #endif private let idBridge = NostrIdentityBridge() init() { let keychain = KeychainManager() let idBridge = self.idBridge _chatViewModel = StateObject( wrappedValue: ChatViewModel( keychain: keychain, idBridge: idBridge, identityManager: SecureIdentityStateManager(keychain) ) ) UNUserNotificationCenter.current().delegate = NotificationDelegate.shared // Warm up georelay directory and refresh if stale (once/day) GeoRelayDirectory.shared.prefetchIfNeeded() } var body: some Scene { WindowGroup { ContentView() .environmentObject(chatViewModel) .onAppear { NotificationDelegate.shared.chatViewModel = chatViewModel // Inject live Noise service into VerificationService to avoid creating new BLE instances VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) // Prewarm Nostr identity and QR to make first VERIFY sheet fast let nickname = chatViewModel.nickname DispatchQueue.global(qos: .utility).async { let npub = try? idBridge.getCurrentNostrIdentity()?.npub _ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub) } appDelegate.chatViewModel = chatViewModel // Initialize network activation policy; will start Tor/Nostr only when allowed NetworkActivationService.shared.start() // Start presence service (will wait for Tor readiness) GeohashPresenceService.shared.start() // Check for shared content checkForSharedContent() } .onOpenURL { url in handleURL(url) } #if os(iOS) .onChange(of: scenePhase) { newPhase in switch newPhase { case .background: // Keep BLE mesh running in background; BLEService adapts scanning automatically // Always send Tor to dormant on background for a clean restart later. TorManager.shared.setAppForeground(false) TorManager.shared.goDormantOnBackground() // Stop geohash sampling while backgrounded Task { @MainActor in chatViewModel.endGeohashSampling() } // Proactively disconnect Nostr to avoid spurious socket errors while Tor is down NostrRelayManager.shared.disconnect() didEnterBackground = true case .active: // Restart services when becoming active chatViewModel.meshService.startServices() TorManager.shared.setAppForeground(true) // On initial cold launch, Tor was just started in onAppear. // Skip the deterministic restart the first time we become active. if didHandleInitialActive && didEnterBackground { if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady { TorManager.shared.ensureRunningOnForeground() } } else { didHandleInitialActive = true } didEnterBackground = false if TorManager.shared.isAutoStartAllowed() { Task.detached { let _ = await TorManager.shared.awaitReady(timeout: 60) await MainActor.run { // Rebuild proxied sessions to bind to the live Tor after readiness TorURLSession.shared.rebuild() // Reconnect Nostr via fresh sessions; will gate until Tor 100% NostrRelayManager.shared.resetAllConnections() } } } checkForSharedContent() case .inactive: break @unknown default: break } } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in // Check for shared content when app becomes active checkForSharedContent() } #elseif os(macOS) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in // App became active } #endif } #if os(macOS) .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) #endif } private func handleURL(_ url: URL) { if url.scheme == "bitchat" && url.host == "share" { // Handle shared content checkForSharedContent() } } private func checkForSharedContent() { // Check app group for shared content from extension guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return } guard let sharedContent = userDefaults.string(forKey: "sharedContent"), let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else { return } // Only process if shared within configured window if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds { let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text" // Clear the shared content userDefaults.removeObject(forKey: "sharedContent") userDefaults.removeObject(forKey: "sharedContentType") userDefaults.removeObject(forKey: "sharedContentDate") // No need to force synchronize here // Send the shared content immediately on the main queue DispatchQueue.main.async { if contentType == "url" { // Try to parse as JSON first if let data = sharedContent.data(using: .utf8), let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String], let url = urlData["url"] { // Send plain URL self.chatViewModel.sendMessage(url) } else { // Fallback to simple URL self.chatViewModel.sendMessage(sharedContent) } } else { self.chatViewModel.sendMessage(sharedContent) } } } } } #if os(iOS) final class AppDelegate: NSObject, UIApplicationDelegate { weak var chatViewModel: ChatViewModel? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { return true } func applicationWillTerminate(_ application: UIApplication) { chatViewModel?.applicationWillTerminate() } } #endif #if os(macOS) import AppKit final class MacAppDelegate: NSObject, NSApplicationDelegate { weak var chatViewModel: ChatViewModel? func applicationWillTerminate(_ notification: Notification) { chatViewModel?.applicationWillTerminate() } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } } #endif final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { static let shared = NotificationDelegate() weak var chatViewModel: ChatViewModel? func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let identifier = response.notification.request.identifier let userInfo = response.notification.request.content.userInfo // Check if this is a private message notification if identifier.hasPrefix("private-") { // Get peer ID from userInfo if let peerID = userInfo["peerID"] as? String { DispatchQueue.main.async { self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID)) } } } // Handle deeplink (e.g., geohash activity) if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) { #if os(iOS) DispatchQueue.main.async { UIApplication.shared.open(url) } #else DispatchQueue.main.async { NSWorkspace.shared.open(url) } #endif } completionHandler() } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let identifier = notification.request.identifier let userInfo = notification.request.content.userInfo // Check if this is a private message notification if identifier.hasPrefix("private-") { // Get peer ID from userInfo if let peerID = userInfo["peerID"] as? String { // Don't show notification if the private chat is already open // Access main-actor-isolated property via Task Task { @MainActor in if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) { completionHandler([]) } else { completionHandler([.banner, .sound]) } } return } } // Suppress geohash activity notification if we're already in that geohash channel if identifier.hasPrefix("geo-activity-"), let deep = userInfo["deeplink"] as? String, let gh = deep.components(separatedBy: "/").last { if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh { completionHandler([]) return } } // Show notification in all other cases completionHandler([.banner, .sound]) } } ================================================ FILE: bitchat/Features/media/ImageUtils.swift ================================================ import Foundation import ImageIO import UniformTypeIdentifiers #if os(iOS) import UIKit #else import AppKit #endif enum ImageUtilsError: Error { case invalidImage case encodingFailed } enum ImageUtils { private static let compressionQuality: CGFloat = 0.82 private static let targetImageBytes: Int = 45_000 static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL { // Security H1: Check file size BEFORE reading into memory let attrs = try FileManager.default.attributesOfItem(atPath: url.path) guard let fileSize = attrs[.size] as? Int else { throw ImageUtilsError.invalidImage } // Allow up to 10MB source images (will be scaled down) guard fileSize <= 10 * 1024 * 1024 else { throw ImageUtilsError.invalidImage } let data = try Data(contentsOf: url) #if os(iOS) guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage } return try processImage(image, maxDimension: maxDimension) #else guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage } return try processImage(image, maxDimension: maxDimension) #endif } #if os(iOS) static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL { return try autoreleasepool { // Scale the image first let scaled = scaledImage(image, maxDimension: maxDimension) // Get CGImage from UIImage - this is the key to stripping metadata guard let cgImage = scaled.cgImage else { throw ImageUtilsError.encodingFailed } // Use CGImageDestination to encode without metadata (same as macOS) var quality = compressionQuality guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else { throw ImageUtilsError.encodingFailed } // Compress to target size while jpegData.count > targetImageBytes && quality > 0.3 { quality -= 0.1 autoreleasepool { if let next = encodeJPEG(from: cgImage, quality: quality) { jpegData = next } } } let outputURL = try makeOutputURL() try jpegData.write(to: outputURL, options: .atomic) return outputURL } } private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage { let size = image.size let maxSide = max(size.width, size.height) guard maxSide > maxDimension else { return image } let scale = maxDimension / maxSide let newSize = CGSize(width: size.width * scale, height: size.height * scale) // Draw into a new context to get a clean CGImage without metadata UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0) image.draw(in: CGRect(origin: .zero, size: newSize)) let rendered = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return rendered ?? image } // Shared EXIF-stripping JPEG encoder for both iOS and macOS private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? { guard let data = CFDataCreateMutable(nil, 0) else { return nil } guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else { return nil } // Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP) // By only specifying compression quality and no metadata keys, // we ensure a clean JPEG with no privacy-leaking information let options: [CFString: Any] = [ kCGImageDestinationLossyCompressionQuality: quality ] CGImageDestinationAddImage(destination, cgImage, options as CFDictionary) guard CGImageDestinationFinalize(destination) else { return nil } return data as Data } #else static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL { return try autoreleasepool { let scaled = scaledImage(image, maxDimension: maxDimension) guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else { throw ImageUtilsError.encodingFailed } let width = inputCG.width let height = inputCG.height let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() guard let context = CGContext( data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { throw ImageUtilsError.encodingFailed } context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height)) guard let cgImage = context.makeImage() else { throw ImageUtilsError.encodingFailed } var quality = compressionQuality guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else { throw ImageUtilsError.encodingFailed } while jpegData.count > targetImageBytes && quality > 0.3 { quality -= 0.1 autoreleasepool { if let next = encodeJPEG(from: cgImage, quality: quality) { jpegData = next } } } let outputURL = try makeOutputURL() try jpegData.write(to: outputURL, options: .atomic) return outputURL } } private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage { let size = image.size let maxSide = max(size.width, size.height) guard maxSide > maxDimension else { return image } let scale = maxDimension / maxSide let newSize = NSSize(width: size.width * scale, height: size.height * scale) let scaledImage = NSImage(size: newSize) scaledImage.lockFocus() image.draw(in: NSRect(origin: .zero, size: newSize), from: NSRect(origin: .zero, size: size), operation: .copy, fraction: 1.0) scaledImage.unlockFocus() return scaledImage } // Shared EXIF-stripping JPEG encoder for both iOS and macOS private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? { guard let data = CFDataCreateMutable(nil, 0) else { return nil } guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else { return nil } // Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP) // By only specifying compression quality and no metadata keys, // we ensure a clean JPEG with no privacy-leaking information let options: [CFString: Any] = [ kCGImageDestinationLossyCompressionQuality: quality ] CGImageDestinationAddImage(destination, cgImage, options as CFDictionary) guard CGImageDestinationFinalize(destination) else { return nil } return data as Data } #endif private static func makeOutputURL() throws -> URL { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" let fileName = "img_\(formatter.string(from: Date())).jpg" let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) return directory.appendingPathComponent(fileName) } private static func applicationFilesDirectory() throws -> URL { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) return base.appendingPathComponent("files", isDirectory: true) } } ================================================ FILE: bitchat/Features/voice/VoiceNotePlaybackController.swift ================================================ import Foundation import AVFoundation import BitLogger /// Controls playback for a single voice note and coordinates exclusive playback across the app. final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate { @Published private(set) var isPlaying: Bool = false @Published private(set) var currentTime: TimeInterval = 0 @Published private(set) var duration: TimeInterval = 0 @Published private(set) var progress: Double = 0 private var player: AVAudioPlayer? private var timer: Timer? private var url: URL init(url: URL) { self.url = url super.init() // Don't load anything eagerly - wait until user interaction or view is fully displayed } func loadDuration() { guard duration == 0 else { return } DispatchQueue.global(qos: .utility).async { [weak self] in guard let self = self else { return } do { let player = try AVAudioPlayer(contentsOf: self.url) let loadedDuration = player.duration DispatchQueue.main.async { [weak self] in guard let self = self, self.duration == 0 else { return } self.duration = loadedDuration } } catch { SecureLogger.error("Failed to load audio duration: \(error)", category: .session) } } } deinit { timer?.invalidate() } func replaceURL(_ url: URL) { guard url != self.url else { return } stop() self.url = url player = nil duration = 0 // Duration will be loaded on demand when needed } func togglePlayback() { isPlaying ? pause() : play() } func play() { guard ensurePlayerReady() else { return } VoiceNotePlaybackCoordinator.shared.activate(self) player?.play() startTimer() updateProgress() isPlaying = true } func pause() { player?.pause() stopTimer() updateProgress() isPlaying = false } func stop() { player?.stop() player?.currentTime = 0 stopTimer() updateProgress() isPlaying = false VoiceNotePlaybackCoordinator.shared.deactivate(self) } func seek(to fraction: Double) { guard ensurePlayerReady() else { return } let clamped = max(0, min(1, fraction)) if let player = player { player.currentTime = clamped * player.duration if isPlaying { player.play() } updateProgress() } } // MARK: - AVAudioPlayerDelegate func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { // Delegate callback may be on background thread - ensure main thread for UI updates DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.stopTimer() self.updateProgress() self.isPlaying = false VoiceNotePlaybackCoordinator.shared.deactivate(self) } } // MARK: - Private Helpers private func preparePlayer(for url: URL) { // Prepare player synchronously (only called when playback is requested) do { let player = try AVAudioPlayer(contentsOf: url) player.delegate = self player.prepareToPlay() self.player = player duration = player.duration currentTime = player.currentTime progress = duration > 0 ? currentTime / duration : 0 } catch { SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session) player = nil duration = 0 currentTime = 0 progress = 0 } } private func ensurePlayerReady() -> Bool { if player == nil { preparePlayer(for: url) } #if os(iOS) let session = AVAudioSession.sharedInstance() do { try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers]) try session.setActive(true, options: []) } catch { SecureLogger.error("Failed to activate audio session: \(error)", category: .session) } #endif return player != nil } private func startTimer() { if timer != nil { return } timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in self?.updateProgress() } if let timer = timer { RunLoop.main.add(timer, forMode: .common) } } private func stopTimer() { timer?.invalidate() timer = nil } private func updateProgress() { guard let player = player else { currentTime = 0 duration = 0 progress = 0 return } currentTime = player.currentTime duration = player.duration progress = duration > 0 ? currentTime / duration : 0 } } /// Ensures only one voice note plays at a time. final class VoiceNotePlaybackCoordinator { static let shared = VoiceNotePlaybackCoordinator() private weak var activeController: VoiceNotePlaybackController? private init() {} func activate(_ controller: VoiceNotePlaybackController) { if activeController === controller { return } activeController?.pause() activeController = controller } func deactivate(_ controller: VoiceNotePlaybackController) { if activeController === controller { activeController = nil } } } ================================================ FILE: bitchat/Features/voice/VoiceRecorder.swift ================================================ import Foundation import AVFoundation /// Manages audio capture for mesh voice notes with predictable encoding settings. /// Recording runs on an internal serial queue to avoid AVAudioSession contention. final class VoiceRecorder: NSObject, AVAudioRecorderDelegate { enum RecorderError: Error { case microphoneAccessDenied case recorderInitializationFailed case recordingInProgress } static let shared = VoiceRecorder() private let queue = DispatchQueue(label: "com.bitchat.voice-recorder") private let paddingInterval: TimeInterval = 0.5 private let maxRecordingDuration: TimeInterval = 120 private var recorder: AVAudioRecorder? private var currentURL: URL? private var stopWorkItem: DispatchWorkItem? private override init() { super.init() } // MARK: - Permissions @discardableResult func requestPermission() async -> Bool { #if os(iOS) return await withCheckedContinuation { continuation in AVAudioSession.sharedInstance().requestRecordPermission { granted in continuation.resume(returning: granted) } } #elseif os(macOS) return await withCheckedContinuation { continuation in AVCaptureDevice.requestAccess(for: .audio) { granted in continuation.resume(returning: granted) } } #else return true #endif } // MARK: - Recording Lifecycle func startRecording() throws -> URL { try queue.sync { if recorder?.isRecording == true { throw RecorderError.recordingInProgress } #if os(iOS) let session = AVAudioSession.sharedInstance() guard session.recordPermission == .granted else { throw RecorderError.microphoneAccessDenied } #if targetEnvironment(simulator) // allowBluetoothHFP is not available on iOS Simulator try session.setCategory( .playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP] ) #else try session.setCategory( .playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP] ) #endif try session.setActive(true, options: .notifyOthersOnDeactivation) #endif #if os(macOS) guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { throw RecorderError.microphoneAccessDenied } #endif let outputURL = try makeOutputURL() let settings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16_000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 16_000 ] let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings) audioRecorder.delegate = self audioRecorder.isMeteringEnabled = true audioRecorder.prepareToRecord() audioRecorder.record(forDuration: maxRecordingDuration) recorder = audioRecorder currentURL = outputURL stopWorkItem?.cancel() stopWorkItem = nil return outputURL } } func stopRecording(completion: @escaping (URL?) -> Void) { queue.async { [weak self] in guard let self = self, let recorder = self.recorder, recorder.isRecording else { completion(self?.currentURL) return } let item = DispatchWorkItem { [weak self] in guard let self = self else { return } recorder.stop() self.cleanupSession() let url = self.currentURL self.recorder = nil self.currentURL = url completion(url) } self.stopWorkItem = item self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item) } } func cancelRecording() { queue.async { [weak self] in guard let self = self else { return } self.stopWorkItem?.cancel() self.stopWorkItem = nil if let recorder = self.recorder, recorder.isRecording { recorder.stop() } self.cleanupSession() if let url = self.currentURL { try? FileManager.default.removeItem(at: url) } self.recorder = nil self.currentURL = nil } } // MARK: - Metering func currentAveragePower() -> Float { queue.sync { recorder?.updateMeters() return recorder?.averagePower(forChannel: 0) ?? -160 } } // MARK: - Helpers private func makeOutputURL() throws -> URL { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" let fileName = "voice_\(formatter.string(from: Date())).m4a" let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true) try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil) return baseDirectory.appendingPathComponent(fileName) } private func applicationFilesDirectory() throws -> URL { #if os(iOS) return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) .appendingPathComponent("files", isDirectory: true) #else let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) return base.appendingPathComponent("files", isDirectory: true) #endif } private func cleanupSession() { #if os(iOS) try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) #endif } } ================================================ FILE: bitchat/Features/voice/Waveform.swift ================================================ import AVFoundation import Foundation import BitLogger /// Generates and caches downsampled waveforms for audio files so UI rendering is cheap. final class WaveformCache { static let shared = WaveformCache() private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent) private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:] private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth private init() {} func cachedWaveform(for url: URL) -> [Float]? { queue.sync { guard let entry = cache[url] else { return nil } return entry.waveform } } func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) { queue.async { [weak self] in guard let self = self else { return } // Check cache (read-only, no update needed on cache hit for performance) if let entry = self.cache[url] { DispatchQueue.main.async { completion(entry.waveform) } return } guard let computed = self.computeWaveform(url: url, bins: bins) else { DispatchQueue.main.async { completion([]) } return } self.queue.async(flags: .barrier) { [weak self] in guard let self = self else { return } // Evict oldest entry if cache is full if self.cache.count >= self.maxCacheSize { if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) { self.cache.removeValue(forKey: oldest.key) } } self.cache[url] = (computed, Date()) } DispatchQueue.main.async { completion(computed) } } } func purge(url: URL) { queue.async(flags: .barrier) { [weak self] in self?.cache.removeValue(forKey: url) } } func purgeAll() { queue.async(flags: .barrier) { [weak self] in self?.cache.removeAll() } } private func computeWaveform(url: URL, bins: Int) -> [Float]? { guard bins > 0 else { return nil } // Use autoreleasepool to manage memory from audio buffer allocations return autoreleasepool { do { let audioFile = try AVAudioFile(forReading: url) let length = Int(audioFile.length) guard length > 0 else { return nil } guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else { return nil } try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length)) guard let channelData = buffer.floatChannelData else { return nil } let channelCount = Int(audioFile.processingFormat.channelCount) let frameLength = Int(buffer.frameLength) let samplesPerBin = max(1, frameLength / bins) var magnitudes: [Float] = Array(repeating: 0, count: bins) for bin in 0..= end { break } var sum: Float = 0 var sampleCount = 0 for frame in start.. 0 ? sum / Float(sampleCount) : 0 } if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 { magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) } } return magnitudes } catch { SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session) return nil } } } } ================================================ FILE: bitchat/Identity/IdentityModels.swift ================================================ // // IdentityModels.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // /// /// # IdentityModels /// /// Defines BitChat's innovative three-layer identity model that balances /// privacy, security, and usability in a decentralized mesh network. /// /// ## Overview /// BitChat's identity system separates concerns across three distinct layers: /// 1. **Ephemeral Identity**: Short-lived, rotatable peer IDs for privacy /// 2. **Cryptographic Identity**: Long-term Noise static keys for security /// 3. **Social Identity**: User-assigned names and trust relationships /// /// This separation allows users to maintain stable cryptographic identities /// while frequently rotating their network identifiers for privacy. /// /// ## Three-Layer Architecture /// /// ### Layer 1: Ephemeral Identity /// - Random 8-byte peer IDs that rotate periodically /// - Provides network-level privacy and prevents tracking /// - Changes don't affect cryptographic relationships /// - Includes handshake state tracking /// /// ### Layer 2: Cryptographic Identity /// - Based on Noise Protocol static key pairs /// - Fingerprint derived from SHA256 of public key /// - Enables end-to-end encryption and authentication /// - Persists across peer ID rotations /// /// ### Layer 3: Social Identity /// - User-assigned names (petnames) for contacts /// - Trust levels from unknown to verified /// - Favorite/blocked status /// - Personal notes and metadata /// /// ## Privacy Design /// The model is designed with privacy-first principles: /// - No mandatory persistent storage /// - Optional identity caching with user consent /// - Ephemeral IDs prevent long-term tracking /// - Social mappings stored locally only /// /// ## Trust Model /// Four levels of trust: /// 1. **Unknown**: New or unverified peers /// 2. **Casual**: Basic interaction history /// 3. **Trusted**: User has explicitly trusted /// 4. **Verified**: Cryptographic verification completed /// /// ## Identity Resolution /// When a peer rotates their ephemeral ID: /// 1. Cryptographic handshake reveals their fingerprint /// 2. System looks up social identity by fingerprint /// 3. UI seamlessly maintains user relationships /// 4. Historical messages remain properly attributed /// /// ## Conflict Resolution /// Handles edge cases like: /// - Multiple peers claiming same nickname /// - Nickname changes and conflicts /// - Identity rotation during active chats /// - Network partitions and rejoins /// /// ## Usage Example /// ```swift /// // When peer connects with new ID /// let ephemeral = EphemeralIdentity(peerID: "abc123", ...) /// // After handshake /// let crypto = CryptographicIdentity(fingerprint: "sha256...", ...) /// // User assigns name /// let social = SocialIdentity(localPetname: "Alice", ...) /// ``` /// import Foundation // MARK: - Three-Layer Identity Model /// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy. /// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships. struct EphemeralIdentity { let peerID: PeerID // 8 random bytes let sessionStart: Date var handshakeState: HandshakeState } enum HandshakeState { case none case initiated case inProgress case completed(fingerprint: String) case failed(reason: String) } /// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair. /// This identity persists across ephemeral ID rotations and enables secure communication. /// The fingerprint serves as the permanent identifier for a peer's cryptographic identity. struct CryptographicIdentity: Codable { let fingerprint: String // SHA256 of public key let publicKey: Data // Noise static public key // Optional Ed25519 signing public key (used to authenticate public messages) var signingPublicKey: Data? = nil let firstSeen: Date let lastHandshake: Date? } /// Represents the social layer of identity - user-assigned names and trust relationships. /// This layer provides human-friendly identification and relationship management. /// All data in this layer is local-only and never transmitted over the network. struct SocialIdentity: Codable { let fingerprint: String var localPetname: String? // User's name for this peer var claimedNickname: String // What peer calls themselves var trustLevel: TrustLevel var isFavorite: Bool var isBlocked: Bool var notes: String? } enum TrustLevel: String, Codable { case unknown = "unknown" case casual = "casual" case trusted = "trusted" case verified = "verified" } // MARK: - Identity Cache /// Persistent storage for identity mappings and relationships. /// Provides efficient lookup between fingerprints, nicknames, and social identities. /// Storage is optional and controlled by user privacy settings. struct IdentityCache: Codable { // Fingerprint -> Social mapping var socialIdentities: [String: SocialIdentity] = [:] // Nickname -> [Fingerprints] reverse index // Multiple fingerprints can claim same nickname var nicknameIndex: [String: Set] = [:] // Verified fingerprints (cryptographic proof) var verifiedFingerprints: Set = [] // Last interaction timestamps (privacy: optional) var lastInteractions: [String: Date] = [:] // Blocked Nostr pubkeys (lowercased hex) for geohash chats var blockedNostrPubkeys: Set = [] // Schema version for future migrations var version: Int = 1 } // // MARK: - Migration Support // ================================================ FILE: bitchat/Identity/SecureIdentityStateManager.swift ================================================ // // SecureIdentityStateManager.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // /// /// # SecureIdentityStateManager /// /// Manages the persistent storage and retrieval of identity mappings with /// encryption at rest. This singleton service maintains the relationship between /// ephemeral peer IDs, cryptographic fingerprints, and social identities. /// /// ## Overview /// The SecureIdentityStateManager provides a secure, privacy-preserving way to /// maintain identity relationships across app launches. It implements: /// - Encrypted storage of identity mappings /// - In-memory caching for performance /// - Thread-safe access patterns /// - Automatic debounced persistence /// /// ## Architecture /// The manager operates at three levels: /// 1. **In-Memory State**: Fast access to active identities /// 2. **Encrypted Cache**: Persistent storage in Keychain /// 3. **Privacy Controls**: User-configurable persistence settings /// /// ## Security Features /// /// ### Encryption at Rest /// - Identity cache encrypted with AES-GCM /// - Unique 256-bit encryption key per device /// - Key stored separately in Keychain /// - No plaintext identity data on disk /// /// ### Privacy by Design /// - Persistence is optional (user-controlled) /// - Minimal data retention /// - No cloud sync or backup /// - Automatic cleanup of stale entries /// /// ### Thread Safety /// - Concurrent read access via GCD barriers /// - Write operations serialized /// - Atomic state updates /// - No data races or corruption /// /// ## Data Model /// Manages three types of identity data: /// 1. **Ephemeral Sessions**: Current peer connections /// 2. **Cryptographic Identities**: Public keys and fingerprints /// 3. **Social Identities**: User-assigned names and trust /// /// ## Persistence Strategy /// - Changes batched and debounced (2-second window) /// - Automatic save on app termination /// - Crash-resistant with atomic writes /// - Migration support for schema changes /// /// ## Usage Patterns /// ```swift /// // Register a new peer identity /// manager.registerPeerIdentity(peerID, publicKey, fingerprint) /// /// // Update social identity /// manager.updateSocialIdentity(fingerprint, nickname, trustLevel) /// /// // Query identity /// let identity = manager.resolvePeerIdentity(peerID) /// ``` /// /// ## Performance Optimizations /// - In-memory cache eliminates Keychain roundtrips /// - Debounced saves reduce I/O operations /// - Efficient data structures for lookups /// - Background queue for expensive operations /// /// ## Privacy Considerations /// - Users can disable all persistence /// - Identity cache can be wiped instantly /// - No analytics or telemetry /// - Ephemeral mode for high-risk users /// /// ## Future Enhancements /// - Selective identity export /// - Cross-device identity sync (optional) /// - Identity attestation support /// - Advanced conflict resolution /// import BitLogger import Foundation import CryptoKit protocol SecureIdentityStateManagerProtocol { // MARK: Secure Loading/Saving func forceSave() // MARK: Social Identity Management func getSocialIdentity(for fingerprint: String) -> SocialIdentity? // MARK: Cryptographic Identities func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] func updateSocialIdentity(_ identity: SocialIdentity) // MARK: Favorites Management func getFavorites() -> Set func setFavorite(_ fingerprint: String, isFavorite: Bool) func isFavorite(fingerprint: String) -> Bool // MARK: Blocked Users Management func isBlocked(fingerprint: String) -> Bool func setBlocked(_ fingerprint: String, isBlocked: Bool) // MARK: Geohash (Nostr) Blocking func isNostrBlocked(pubkeyHexLowercased: String) -> Bool func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) func getBlockedNostrPubkeys() -> Set // MARK: Ephemeral Session Management func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) func updateHandshakeState(peerID: PeerID, state: HandshakeState) // MARK: Cleanup func clearAllIdentityData() func removeEphemeralSession(peerID: PeerID) // MARK: Verification func setVerified(fingerprint: String, verified: Bool) func isVerified(fingerprint: String) -> Bool func getVerifiedFingerprints() -> Set } /// Singleton manager for secure identity state persistence and retrieval. /// Provides thread-safe access to identity mappings with encryption at rest. /// All identity data is stored encrypted in the device Keychain for security. final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { private let keychain: KeychainManagerProtocol private let cacheKey = "bitchat.identityCache.v2" private let encryptionKeyName = "identityCacheEncryptionKey" // In-memory state private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:] private var cryptographicIdentities: [String: CryptographicIdentity] = [:] private var cache: IdentityCache = IdentityCache() // Thread safety private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) // Debouncing for keychain saves private var saveTimer: Timer? private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds private var pendingSave = false // Encryption key private let encryptionKey: SymmetricKey init(_ keychain: KeychainManagerProtocol) { self.keychain = keychain // Generate or retrieve encryption key from keychain let loadedKey: SymmetricKey // Try to load from keychain if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) { loadedKey = SymmetricKey(data: keyData) SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true) } // Generate new key if needed else { loadedKey = SymmetricKey(size: .bits256) let keyData = loadedKey.withUnsafeBytes { Data($0) } // Save to keychain let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName) SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved) } self.encryptionKey = loadedKey // Load identity cache on init loadIdentityCache() } deinit { forceSave() } // MARK: - Secure Loading/Saving private func loadIdentityCache() { guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else { // No existing cache, start fresh return } do { let sealedBox = try AES.GCM.SealedBox(combined: encryptedData) let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey) cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData) } catch { // Log error but continue with empty cache SecureLogger.error(error, context: "Failed to load identity cache", category: .security) } } private func saveIdentityCache() { // Mark that we need to save pendingSave = true // Cancel any existing timer saveTimer?.invalidate() // Schedule a new save after the debounce interval saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in self?.performSave() } } private func performSave() { guard pendingSave else { return } pendingSave = false do { let data = try JSONEncoder().encode(cache) let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey) if saved { SecureLogger.debug("Identity cache saved to keychain", category: .security) } } catch { SecureLogger.error(error, context: "Failed to save identity cache", category: .security) } } // Force immediate save (for app termination) func forceSave() { saveTimer?.invalidate() performSave() } // MARK: - Social Identity Management func getSocialIdentity(for fingerprint: String) -> SocialIdentity? { queue.sync { return cache.socialIdentities[fingerprint] } } // MARK: - Cryptographic Identities /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname. /// - Parameters: /// - fingerprint: SHA-256 hex of the Noise static public key /// - noisePublicKey: Noise static public key data /// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages /// - claimedNickname: Optional latest claimed nickname to persist into social identity func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) { queue.async(flags: .barrier) { let now = Date() if var existing = self.cryptographicIdentities[fingerprint] { // Update keys if changed if existing.publicKey != noisePublicKey { existing = CryptographicIdentity( fingerprint: fingerprint, publicKey: noisePublicKey, signingPublicKey: signingPublicKey ?? existing.signingPublicKey, firstSeen: existing.firstSeen, lastHandshake: now ) self.cryptographicIdentities[fingerprint] = existing } else { // Update signing key and lastHandshake existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey let updated = CryptographicIdentity( fingerprint: existing.fingerprint, publicKey: existing.publicKey, signingPublicKey: existing.signingPublicKey, firstSeen: existing.firstSeen, lastHandshake: now ) self.cryptographicIdentities[fingerprint] = updated } // Persist updated state (already assigned in branches above) } else { // New entry let entry = CryptographicIdentity( fingerprint: fingerprint, publicKey: noisePublicKey, signingPublicKey: signingPublicKey, firstSeen: now, lastHandshake: now ) self.cryptographicIdentities[fingerprint] = entry } // Optionally persist claimed nickname into social identity if let claimed = claimedNickname { var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity( fingerprint: fingerprint, localPetname: nil, claimedNickname: claimed, trustLevel: .unknown, isFavorite: false, isBlocked: false, notes: nil ) // Update claimed nickname if changed if identity.claimedNickname != claimed { identity.claimedNickname = claimed self.cache.socialIdentities[fingerprint] = identity } else if self.cache.socialIdentities[fingerprint] == nil { self.cache.socialIdentities[fingerprint] = identity } } self.saveIdentityCache() } } /// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] { queue.sync { // Defensive: ensure hex and correct length guard peerID.isShort else { return [] } return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } } } func updateSocialIdentity(_ identity: SocialIdentity) { queue.async(flags: .barrier) { let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname self.cache.socialIdentities[identity.fingerprint] = identity // Update nickname index if let previousClaimedNickname, previousClaimedNickname != identity.claimedNickname { self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint) if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true { self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname) } } // Add new nickname to index if self.cache.nicknameIndex[identity.claimedNickname] == nil { self.cache.nicknameIndex[identity.claimedNickname] = Set() } self.cache.nicknameIndex[identity.claimedNickname]?.insert(identity.fingerprint) // Save to keychain self.saveIdentityCache() } } // MARK: - Favorites Management func getFavorites() -> Set { queue.sync { let favorites = cache.socialIdentities.values .filter { $0.isFavorite } .map { $0.fingerprint } return Set(favorites) } } func setFavorite(_ fingerprint: String, isFavorite: Bool) { queue.async(flags: .barrier) { if var identity = self.cache.socialIdentities[fingerprint] { identity.isFavorite = isFavorite self.cache.socialIdentities[fingerprint] = identity } else { // Create new social identity for this fingerprint let newIdentity = SocialIdentity( fingerprint: fingerprint, localPetname: nil, claimedNickname: "Unknown", trustLevel: .unknown, isFavorite: isFavorite, isBlocked: false, notes: nil ) self.cache.socialIdentities[fingerprint] = newIdentity } self.saveIdentityCache() } } func isFavorite(fingerprint: String) -> Bool { queue.sync { return cache.socialIdentities[fingerprint]?.isFavorite ?? false } } // MARK: - Blocked Users Management func isBlocked(fingerprint: String) -> Bool { queue.sync { return cache.socialIdentities[fingerprint]?.isBlocked ?? false } } func setBlocked(_ fingerprint: String, isBlocked: Bool) { SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security) queue.async(flags: .barrier) { if var identity = self.cache.socialIdentities[fingerprint] { identity.isBlocked = isBlocked if isBlocked { identity.isFavorite = false // Can't be both favorite and blocked } self.cache.socialIdentities[fingerprint] = identity } else { // Create new social identity for this fingerprint let newIdentity = SocialIdentity( fingerprint: fingerprint, localPetname: nil, claimedNickname: "Unknown", trustLevel: .unknown, isFavorite: false, isBlocked: isBlocked, notes: nil ) self.cache.socialIdentities[fingerprint] = newIdentity } self.saveIdentityCache() } } // MARK: - Geohash (Nostr) Blocking func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { queue.sync { return cache.blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased()) } } func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { let key = pubkeyHexLowercased.lowercased() queue.async(flags: .barrier) { if isBlocked { self.cache.blockedNostrPubkeys.insert(key) } else { self.cache.blockedNostrPubkeys.remove(key) } self.saveIdentityCache() } } func getBlockedNostrPubkeys() -> Set { queue.sync { cache.blockedNostrPubkeys } } // MARK: - Ephemeral Session Management func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) { queue.async(flags: .barrier) { self.ephemeralSessions[peerID] = EphemeralIdentity( peerID: peerID, sessionStart: Date(), handshakeState: handshakeState ) } } func updateHandshakeState(peerID: PeerID, state: HandshakeState) { queue.async(flags: .barrier) { self.ephemeralSessions[peerID]?.handshakeState = state // If handshake completed, update last interaction if case .completed(let fingerprint) = state { self.cache.lastInteractions[fingerprint] = Date() self.saveIdentityCache() } } } // MARK: - Cleanup func clearAllIdentityData() { SecureLogger.warning("Clearing all identity data", category: .security) queue.async(flags: .barrier) { self.cache = IdentityCache() self.ephemeralSessions.removeAll() self.cryptographicIdentities.removeAll() // Delete from keychain let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted) } } func removeEphemeralSession(peerID: PeerID) { queue.async(flags: .barrier) { self.ephemeralSessions.removeValue(forKey: peerID) } } // MARK: - Verification func setVerified(fingerprint: String, verified: Bool) { SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security) queue.async(flags: .barrier) { if verified { self.cache.verifiedFingerprints.insert(fingerprint) } else { self.cache.verifiedFingerprints.remove(fingerprint) } // Update trust level if social identity exists if var identity = self.cache.socialIdentities[fingerprint] { identity.trustLevel = verified ? .verified : .casual self.cache.socialIdentities[fingerprint] = identity } self.saveIdentityCache() } } func isVerified(fingerprint: String) -> Bool { queue.sync { return cache.verifiedFingerprints.contains(fingerprint) } } func getVerifiedFingerprints() -> Set { queue.sync { return cache.verifiedFingerprints } } var debugNicknameIndex: [String: Set] { queue.sync { cache.nicknameIndex } } func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? { queue.sync { ephemeralSessions[peerID] } } func debugLastInteraction(for fingerprint: String) -> Date? { queue.sync { cache.lastInteractions[fingerprint] } } } ================================================ FILE: bitchat/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName bitchat CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType APPL CFBundleShortVersionString $(MARKETING_VERSION) CFBundleURLTypes CFBundleURLSchemes bitchat CFBundleVersion $(CURRENT_PROJECT_VERSION) LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSBluetoothAlwaysUsageDescription bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. NSBluetoothPeripheralUsageDescription bitchat uses Bluetooth to discover and connect with other bitchat users nearby. NSCameraUsageDescription bitchat uses the camera to scan QR codes to verify peers. NSPhotoLibraryUsageDescription bitchat lets you pick images from your photo library to share with nearby peers. NSMicrophoneUsageDescription bitchat uses the microphone to record voice notes that relay across the mesh. NSLocationWhenInUseUsageDescription bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared. UIBackgroundModes bluetooth-central bluetooth-peripheral UILaunchStoryboardName LaunchScreen UIRequiresFullScreen UISupportedInterfaceOrientations UIInterfaceOrientationPortrait ================================================ FILE: bitchat/LaunchScreen.storyboard ================================================ ================================================ FILE: bitchat/Localizable.xcstrings ================================================ { "sourceLanguage" : "en", "strings" : { "%@" : { "comment" : "Non-localizable symbol used in code", "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "%@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "%@" } } }, "shouldTranslate" : false }, "%@ active" : { "comment" : "A label at the bottom of the people list sheet showing the number of active users.", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "%@ نشطين" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%@ সক্রিয়" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%@ aktiv" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "%@ active" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "%@ activos" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "%@ aktibo" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ actifs" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "%@ פעילים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%@ सक्रिय" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "%@ aktif" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "%@ attivi" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@ 人がアクティブ" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 사용자 %@명" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "%@ aktif" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%@ सक्रिय" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "%@ actief" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "%@ aktywni" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "%@ ativos" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "%@ ativos" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "%@ активных" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "%@ aktiva" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%@ செயலில்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "%@ กำลังใช้งาน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%@ aktif" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "%@ активних" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%@ فعال" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "%@ đang hoạt động" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "活跃用户 %@ 名" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "活躍使用者 %@ 位" } } } }, "app_info.app_name" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "bitchat" } } } }, "app_info.close" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إغلاق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বন্ধ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "schließen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "close" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cerrar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "isara" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "fermer" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "סגור" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "बंद करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tutup" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "chiudi" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "閉じる" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "닫기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tutup" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "बन्द" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "sluiten" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zamknij" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "fechar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "fechar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "закрыть" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "stäng" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "மூடு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ปิด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kapat" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "закрити" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بند کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đóng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "关闭" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "關閉" } } } }, "app_info.done" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تم" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "সম্পন্ন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "FERTIG" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "DONE" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "LISTO" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "TAPOS" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "TERMINÉ" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "בוצע" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "समाप्त" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "SELESAI" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "FATTO" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "完了" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "확인" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "SELESAI" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सम्पन्न" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "KLAAR" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "GOTOWE" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "CONCLUÍDO" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "CONCLUÍDO" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "ГОТОВО" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "KLART" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "முடிந்தது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เสร็จสิ้น" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "BİTTİ" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "ГОТОВО" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "مکمل" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "XONG" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "完成" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "完成" } } } }, "app_info.features.encryption.description" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "الرسائل الخاصة مشفرة ببروتوكول noise" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "গোপন বার্তাগুলো নোইজ প্রোটোকলের মাধ্যমে এনক্রিপ্ট করা হয়" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "private nachrichten werden mit dem noise-protokoll verschlüsselt" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "private messages encrypted with noise protocol" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "mensajes privados cifrados con el protocolo Noise" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "ang mga pribadong mensahe ay ini-encrypt gamit ang Noise protocol" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "messages privés chiffrés avec le protocole noise" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הודעות פרטיות מוצפנות בפרוטוקול noise" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "नॉइज़ प्रोटोकॉल से निजी संदेश एन्क्रिप्ट किए जाते हैं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "pesan pribadi dienkripsi dengan protokol noise" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "messaggi privati cifrati con il protocollo noise" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "プライベートメッセージはnoiseプロトコルで暗号化されます" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "비공개 메시지가 노이즈 프로토콜로 암호화됩니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "pesan pribadi dienkripsi dengan protokol noise" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "व्यक्तिगत सन्देशहरू noise प्रोटोकलले सङ्केत गर्छ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "privéberichten worden versleuteld met het Noise-protocol" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "prywatne wiadomości są szyfrowane protokołem Noise" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "mensagens privadas encriptadas com o protocolo Noise" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "mensagens privadas criptografadas com o protocolo noise" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "личные сообщения шифруются протоколом noise" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "privata meddelanden krypteras med Noise-protokollet" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "தனிப்பட்ட செய்திகள் Noise நெறிமுறையால் குறியாக்கம் செய்யப்படுகின்றன" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ข้อความส่วนตัวถูกเข้ารหัสด้วยโปรโตคอล Noise" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "özel mesajlar noise protokolü ile şifrelenir" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "приватні повідомлення шифруються протоколом noise" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نجی پیغامات Noise پروٹوکول سے خفیہ کیے جاتے ہیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "tin nhắn riêng tư được mã hóa bằng giao thức Noise" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "私密消息使用 noise 协议加密" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "私密訊息使用 noise 協議加密" } } } }, "app_info.features.encryption.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تشفير طرف لطرف" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এন্ড-টু-এন্ড এনক্রিপশন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "end-to-end-verschlüsselung" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "end-to-end encryption" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cifrado de extremo a extremo" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "end-to-end na pag-encrypt" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "chiffrement de bout en bout" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הצפנה מקצה לקצה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एंड-टू-एंड एन्क्रिप्शन" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "enkripsi ujung ke ujung" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "crittografia end-to-end" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "エンドツーエンド暗号" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "종단간 암호화" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "enkripsi ujung ke ujung" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "एन्ड-टु-एन्ड सङ्केत" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "end-to-end-versleuteling" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "szyfrowanie end-to-end" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "encriptação ponta a ponta" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "criptografia ponto a ponto" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "сквозное шифрование" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ända-till-ände-kryptering" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "முற்றிலும் குறியாக்கம்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "การเข้ารหัสปลายทางถึงปลายทาง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "uçtan uca şifreleme" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "скрізьове шифрування" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "اینڈ ٹو اینڈ انکرپشن" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "mã hóa đầu cuối" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "端到端加密" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "端到端加密" } } } }, "app_info.features.extended_range.description" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "يُعاد تمرير الرسائل بين الأقران لتصل لمسافات أبعد" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বার্তাগুলো সহ-পিয়ারদের মাধ্যমে রিলে হয়ে দূরেও পৌঁছে যায়" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "nachrichten werden zwischen peers weitergeleitet und reichen weiter" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "messages relay through peers, going the distance" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "los mensajes se retransmiten entre pares y llegan lejos" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "ipinapasa ang mga mensahe sa mga peer para makarating sa malalayo" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "messages relayés entre pairs pour aller plus loin" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הודעות משודרות בין עמיתים ומגיעות רחוק יותר" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "संदेश साथियों के माध्यम से रिले होकर दूर तक पहुँचते हैं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "pesan diteruskan antar peer sehingga jangkauannya lebih jauh" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "i messaggi vengono inoltrati tra peer per arrivare più lontano" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "メッセージはピア間でリレーされより遠くに届きます" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "메시지가 피어를 통해 중계되어 더 멀리 도달합니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "pesan diteruskan antar peer sehingga jangkauannya lebih jauh" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सन्देशहरू सहकर्मीमार्फत रिले भएर टाढासम्म पुग्छन्" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "berichten worden via peers doorgestuurd om verder te reiken" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wiadomości są przekazywane przez peerów, aby dotrzeć dalej" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "mensagens retransmitidas entre pares para chegar mais longe" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "mensagens retransmitidas entre pares para alcançar mais longe" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "сообщения ретранслируются между пирами и уходят дальше" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "meddelanden vidarebefordras via peers för att nå längre" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "செய்திகள் துணை இணைப்புகளின் மூலம் பரிமாறி தூரம் சென்றடைகின்றன" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ข้อความส่งต่อผ่านเพียร์เพื่อไปได้ไกลขึ้น" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "mesajlar eşler üzerinden aktarılarak uzağa ulaşır" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "повідомлення ретранслюються між пірами й долітають далі" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "پیغامات ہم منصبوں کے ذریعے آگے بڑھا کر دور تک پہنچتے ہیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "tin nhắn được chuyển tiếp qua các nút ngang hàng để đi xa hơn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "消息通过同伴中继,传得更远" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "訊息透過同伴中繼,傳得更遠" } } } }, "app_info.features.extended_range.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "نطاق ممتد" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বর্ধিত পরিসর" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "erweiterte reichweite" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "extended range" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "alcance ampliado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "mas malawak na saklaw" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "portée étendue" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "טווח מורחב" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "विस्तारित दायरा" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "jangkauan diperluas" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "portata estesa" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "拡張レンジ" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "확장된 범위" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "jangkauan diperluas" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "विस्तारित पहुँच" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "groter bereik" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "większy zasięg" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "alcance alargado" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "alcance estendido" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "расширенный радиус" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "utökat räckvidd" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "விரிந்த வரம்பு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ระยะสื่อสารที่กว้างขึ้น" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "genişletilmiş menzil" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "розширена дальність" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "وسیع دائرہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "phạm vi mở rộng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "扩展范围" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "擴展範圍" } } } }, "app_info.features.favorites.description" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تلقَّ تنبيهات عندما ينضم أحباؤك" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "আপনার প্রিয় মানুষ যোগ দিলে বিজ্ঞপ্তি পান" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "erhalte hinweise, wenn deine lieblingsmenschen online kommen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "get notified when your favorite people join" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "recibe avisos cuando tus personas favoritas se conecten" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "tumanggap ng abiso kapag sumali ang paborito mong mga tao" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "reçois une alerte quand tes personnes favorites arrivent" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "קבל התראות כשהאנשים המועדפים שלך מצטרפים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "जब आपके पसंदीदा लोग जुड़ें तो सूचना पाएं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "dapatkan notifikasi saat orang favoritmu bergabung" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "ricevi avvisi quando entrano le tue persone preferite" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "お気に入りの人が参加したら通知を受け取れます" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "즐겨찾는 사용자가 참여하면 알림을 받습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "dapatkan notifikasi saat orang favoritmu bergabung" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "तिम्रा मनपर्ने मानिस जोडिएपछि सूचनाहरू पाऊ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "ontvang meldingen wanneer je favoriete mensen meedoen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "otrzymuj powiadomienia, gdy dołączają twoje ulubione osoby" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "recebe notificações quando as tuas pessoas favoritas entram" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "receba avisos quando suas pessoas favoritas entrarem" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "получай уведомления, когда подключаются любимые люди" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "få aviseringar när dina favoriter ansluter" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "உங்களுக்குப் பிரியமானவர்கள் சேர்ந்தவுடன் அறிவிப்பு பெறுங்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "รับการแจ้งเตือนเมื่อคนโปรดของคุณเข้าร่วม" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "favori kişileriniz katıldığında bildirim alın" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "отримуй сповіщення, коли підключаються улюблені люди" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "جب آپ کے پسندیدہ لوگ شامل ہوں تو اطلاع پائیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "nhận thông báo khi những người yêu thích của bạn tham gia" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "你喜欢的人加入时立刻提醒" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "你喜歡的人加入時立刻提醒" } } } }, "app_info.features.favorites.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "المفضلة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "প্রিয়সমূহ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "favoriten" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "favorites" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "favoritos" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "mga paborito" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "favoris" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מועדפים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "पसंदीदा" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "favorit" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "preferiti" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "お気に入り" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "즐겨찾기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "favorit" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "मनपर्ने" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "favorieten" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "ulubione" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "favoritos" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "favoritos" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "избранное" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "favoriter" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சிறப்புப் பட்டியல்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "รายการโปรด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "favoriler" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "вибране" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "پسندیدہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "yêu thích" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "收藏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "收藏" } } } }, "app_info.features.geohash.description" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "قنوات geohash للدردشة مع أشخاص قريبين عبر مرحلات لامركزية مجهولة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ডিসেন্ট্রালাইজড বেনামি রিলে দিয়ে কাছাকাছি অঞ্চলের মানুষের সাথে কথা বলার জন্য জিওহ্যাশ চ্যানেল" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "geohash-kanäle zum chatten mit menschen in der nähe über dezentrale anonyme relays" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "geohash channels to chat with people in nearby regions over decentralized anonymous relays" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "canales geohash para chatear con personas en regiones cercanas a través de relays descentralizados anónimos" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "mga geohash channel para makipag-chat sa mga tao sa karatig na lugar sa pamamagitan ng mga desentralisado at anonimong relay" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "canaux geohash pour discuter avec des personnes proches via des relais décentralisés anonymes" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "ערוצי geohash לשיחה עם אנשים קרובים דרך ממסרים אנונימיים מבוזרים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "विकेंद्रीकृत गुमनाम रिले के माध्यम से आसपास के क्षेत्रों के लोगों से चैट करने के लिए जियोहैश चैनल" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "kanal geohash untuk ngobrol dengan orang di wilayah sekitar lewat relay anonim terdesentralisasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "canali geohash per chattare con persone vicine tramite relay anonimi decentralizzati" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "geohashチャンネルで近くの人と匿名分散リレー越しにチャット" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "분산형 익명 릴레이를 통해 geohash 채널에서 주변 지역의 사람들과 대화할 수 있습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "kanal geohash untuk ngobrol dengan orang di wilayah sekitar lewat relay anonim terdesentralisasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "geohash च्यानलहरूले नजिकका व्यक्तिसँग विकेन्द्रित गोप्य रिलेबाट कुराकानी गर्न मद्दत गर्छ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "geohash-kanalen om te chatten met mensen in de buurt via gedecentraliseerde, anonieme relais" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "kanały geohash do rozmów z osobami w pobliżu przez zdecentralizowane, anonimowe przekaźniki" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "canais geohash para conversar com pessoas em regiões próximas através de relés descentralizados anónimos" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "canais geohash para conversar com pessoas em regiões próximas por relays descentralizados anônimos" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "каналы geohash для чата с людьми поблизости через децентрализованные анонимные реле" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "geohash-kanaler för att chatta med folk i närheten via decentraliserade, anonyma reläer" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "மையமற்ற மறை நெட்வொர்க் ரிலேக்கள் மூலம் அருகிலுள்ள பகுதிகளில் உள்ளவர்களுடன் பேச geohash சேனல்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ช่อง geohash สำหรับพูดคุยกับคนในพื้นที่ใกล้เคียงผ่านรีเลย์แบบกระจายศูนย์และไม่ระบุตัว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "merkeziyetsiz anonim röleler üzerinden yakın bölgelerdeki insanlarla sohbet etmek için geohash kanalları" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "канали geohash для спілкування з людьми поблизу через децентралізовані анонімні ретранслятори" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "غیر مرکزی گمنام ریلوں کے ذریعے قریب کے لوگوں سے بات کرنے کیلئے geohash چینلز" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "các kênh geohash để trò chuyện với mọi người ở vùng lân cận thông qua các relay ẩn danh phi tập trung" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "geohash 频道让你通过去中心化匿名中继与附近地区的人聊天" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "geohash 頻道讓你透過去中心化匿名中繼與附近地區的人聊天" } } } }, "app_info.features.geohash.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "قنوات محلية" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "স্থানীয় চ্যানেল" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "lokale kanäle" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "local channels" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "canales locales" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "mga lokal na channel" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "canaux locaux" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "ערוצים מקומיים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "स्थानीय चैनल" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "kanal lokal" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "canali locali" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ローカルチャンネル" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "지역 채널" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "kanal lokal" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "स्थानीय च्यानल" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "lokale kanalen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "kanały lokalne" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "canais locais" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "canais locais" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "локальные каналы" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "lokala kanaler" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "உள்ளூர் சேனல்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ช่องท้องถิ่น" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "yerel kanallar" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "локальні канали" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "مقامی چینلز" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "kênh địa phương" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "本地频道" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "本地頻道" } } } }, "app_info.features.mentions.description" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "استخدم @nickname لتنبيه أشخاص محددين" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "নির্দিষ্ট কাউকে জানানোর জন্য @nickname ব্যবহার করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "nutze @nickname, um bestimmte personen zu benachrichtigen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "use @nickname to notify specific people" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "usa @nickname para avisar a personas concretas" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "gumamit ng @nickname para abisuhan ang partikular na tao" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "utilise @nickname pour avertir des personnes précises" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "השתמש ב-@nickname כדי להתריע לאנשים ספציפיים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "विशेष लोगों को सूचित करने के लिए @nickname उपयोग करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "pakai @nickname untuk memberi tahu orang tertentu" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "usa @nickname per avvisare persone specifiche" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "@nicknameで特定の人に通知" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "@닉네임을 사용하여 특정 사람에게 알림을 보낼 수 있습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "guna @nickname untuk memberi tahu orang tertentu" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "विशेष व्यक्तिलाई सूचित गर्न @nickname प्रयोग गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "gebruik @nickname om een specifiek persoon te pingen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "użyj @nickname, aby powiadomić konkretną osobę" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "usa @nickname para avisar pessoas específicas" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "use @nickname para notificar pessoas específicas" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "используй @nickname, чтобы уведомить конкретных людей" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "använd @nickname för att meddela en specifik person" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறிப்பிட்டவரை அறிவிக்க @nickname பயன்படுத்துங்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ใช้ @nickname เพื่อแจ้งเตือนบุคคลเฉพาะ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "belirli kişileri bildirmek için @nickname kullanın" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "використовуй @nickname, щоб сповістити конкретних людей" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "کسی مخصوص شخص کو خبردار کرنے کیلئے @nickname استعمال کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "dùng @nickname để thông báo cho người cụ thể" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "使用 @nickname 提醒特定的人" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "使用 @nickname 提醒特定的人" } } } }, "app_info.features.mentions.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إشارات" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "মেনশন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "erwähnungen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "mentions" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "menciones" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "mga banggit" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "mentions" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אזכורים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "मेंशन" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "mention" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "menzioni" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "メンション" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "멘션" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "mention" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "उल्लेख" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "vermeldingen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wzmianki" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "menções" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "menções" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "упоминания" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "omnämnanden" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "மேற்கோள்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "การกล่าวถึง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bahsetmeler" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "згадки" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ذکر" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "nhắc tới" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "提及" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "提及" } } } }, "app_info.features.offline.description" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "يعمل بدون إنترنت باستخدام bluetooth منخفض الطاقة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্লুটুথ লো এনার্জি ব্যবহার করে ইন্টারনেট ছাড়াই কাজ করে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "funktioniert ohne internet per bluetooth low energy" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "works without internet using Bluetooth low energy" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "funciona sin internet utilizando Bluetooth de bajo consumo" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "gumagana kahit walang internet gamit ang Bluetooth Low Energy" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "fonctionne sans internet avec le bluetooth basse énergie" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "עובד בלי אינטרנט באמצעות bluetooth בתצריכת אנרגיה נמוכה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "ब्लूटूथ लो एनर्जी से इंटरनेट बिना भी काम करता है" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "bekerja tanpa internet memakai bluetooth low energy" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "funziona senza internet usando bluetooth a basso consumo" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth low energyでインターネットなしでも動作" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "저전력 bluetooth를 사용하여 인터넷 없이 작동합니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "berfungsi tanpa internet menggunakan bluetooth low energy" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth low energy प्रयोग गरेर इन्टरनेट बिना काम गर्छ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "werkt zonder internet met Bluetooth Low Energy" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "działa bez internetu dzięki Bluetooth Low Energy" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "funciona sem internet usando Bluetooth de baixo consumo" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "funciona sem internet usando bluetooth de baixa energia" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "работает без интернета через bluetooth low energy" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "fungerar utan internet med Bluetooth Low Energy" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth குறைந்த மின்சாரத்தைப் பயன்படுத்தி இணையமின்றி இயங்குகிறது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ทำงานได้แม้ไม่มีอินเทอร์เน็ตด้วย Bluetooth พลังงานต่ำ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth Low Energy kullanarak internet olmadan çalışır" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "працює без інтернету через bluetooth low energy" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth Low Energy کے ساتھ بغیر انٹرنیٹ کے کام کرتا ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "hoạt động không cần internet bằng Bluetooth năng lượng thấp" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "利用低功耗 bluetooth 离线工作" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "利用低功耗 bluetooth 離線工作" } } } }, "app_info.features.offline.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تواصل بدون اتصال" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "অফলাইন যোগাযোগ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "offline-kommunikation" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "offline communication" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "comunicación sin conexión" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "offline na komunikasyon" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "communication hors ligne" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "תקשורת לא מקוונת" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "ऑफलाइन संचार" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "komunikasi offline" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "comunicazione offline" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "オフライン通信" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "오프라인 통신" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "komunikasi offline" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "अफलाइन सञ्चार" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "offline communicatie" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "komunikacja offline" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "comunicação offline" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "comunicação offline" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "офлайн-связь" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "offlinekommunikation" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "ஆஃப்லைன் தொடர்பு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "การสื่อสารออฟไลน์" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "çevrimdışı iletişim" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "офлайн-зв'язок" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "آف لائن مواصلات" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "liên lạc ngoại tuyến" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "离线通信" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "離線通信" } } } }, "app_info.features.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مزايا" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বৈশিষ্ট্য" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "FUNKTIONEN" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "FEATURES" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "FUNCIONES" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "MGA TAMPOK" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "FONCTIONNALITÉS" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "יכולות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "विशेषताएँ" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "FITUR" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "FUNZIONI" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "機能" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "기능" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "FITUR" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "विशेषता" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "FUNCTIES" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "FUNKCJE" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "FUNCIONALIDADES" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "RECURSOS" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "ВОЗМОЖНОСТИ" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "FUNKTIONER" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அம்சங்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ฟีเจอร์" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "ÖZELLİKLER" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "МОЖЛИВОСТІ" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "خصوصیات" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "TÍNH NĂNG" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "功能" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "功能" } } } }, "app_info.how_to_use.change_channels" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "• اضغط #mesh لتغيير القناة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "• চ্যানেল বদলাতে #mesh ট্যাপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "• tippe auf #mesh, um den kanal zu wechseln" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "• tap #mesh to change channels" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "• toca #mesh para cambiar de canal" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "• i-tap ang #mesh para magpalit ng channel" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "• tape sur #mesh pour changer de canal" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "• הקש על #mesh כדי להחליף ערוץ" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "• चैनल बदलने के लिए #mesh टैप करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "• ketuk #mesh untuk ganti kanal" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "• tocca #mesh per cambiare canale" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "• #meshをタップしてチャンネルを切り替え" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "• #mesh를 탭하여 채널을 변경합니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "• ketuk #mesh untuk ganti kanal" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "• च्यानल बदल्न #mesh ट्याप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "• tik op #mesh om van kanaal te wisselen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "• stuknij #mesh, aby zmienić kanał" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "• toca em #mesh para mudar de canal" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "• toque #mesh para trocar de canal" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "• нажми #mesh, чтобы сменить канал" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "• tryck på #mesh för att byta kanal" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "• சேனலை மாற்ற #mesh ஐத் தட்டவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "• แตะ #mesh เพื่อเปลี่ยนช่อง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "• kanalı değiştirmek için #mesh'e dokunun" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "• торкнися #mesh, щоб змінити канал" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "• چینل بدلنے کیلئے #mesh پر ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "• chạm #mesh để đổi kênh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "• 轻点 #mesh 切换频道" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "• 輕點 #mesh 切換頻道" } } } }, "app_info.how_to_use.clear_chat" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "• اضغط الدردشة ثلاث مرات للمسح" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "• চ্যাট মুছতে তিনবার ট্যাপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "• tippe den chat dreimal, um ihn zu leeren" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "• triple-tap chat to clear" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "• toca tres veces el chat para limpiarlo" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "• i-triple tap ang chat para linisin" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "• tape trois fois sur le chat pour le vider" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "• הקש שלוש פעמים על הצ'אט כדי לנקות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "• चैट साफ़ करने के लिए तीन बार टैप करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "• ketuk chat tiga kali untuk menghapus" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "• tocca tre volte la chat per svuotarla" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "• チャットを3回タップするとクリア" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "• 대화를 세 번 탭하여 삭제합니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "• ketuk chat tiga kali untuk menghapus" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "• च्याट खाली गर्न तीन पटक ट्याप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "• tik drie keer op de chat om te wissen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "• stuknij okno czatu trzykrotnie, aby wyczyścić" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "• toca três vezes no chat para o limpar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "• toque o chat três vezes para limpar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "• тройной тап по чату очистит его" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "• tryck tre gånger i chatten för att rensa" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "• உரையாடலை அழிக்க மூன்று முறை தட்டுங்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "• แตะหน้าจอแชทสามครั้งเพื่อล้างข้อความ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "• sohbeti temizlemek için sohbete üç kez dokunun" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "• торкни чат тричі, щоб очистити" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "• چیٹ صاف کرنے کیلئے تین بار ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "• chạm ba lần vào khung chat để xóa" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "• 三击聊天即可清除" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "• 三擊聊天即可清除" } } } }, "app_info.how_to_use.commands" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "• اكتب / لعرض الأوامر" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "• কমান্ডের জন্য / টাইপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "• tippe /, um befehle zu sehen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "• type / for commands" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "• escribe / para ver los comandos" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "• mag-type ng / para sa mga utos" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "• tape / pour voir les commandes" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "• הקלד / כדי לראות פקודות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "• कमांड के लिए / टाइप करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "• ketik / untuk melihat perintah" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "• digita / per vedere i comandi" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "• /を入力してコマンド表示" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "• /를 입력하여 명령어를 사용합니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "• ketik / untuk melihat perintah" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "• आदेशहरू हेर्न / टाइप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "• typ / voor opdrachten" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "• wpisz /, aby zobaczyć polecenia" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "• escreve / para ver os comandos" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "• digite / para ver comandos" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "• введи /, чтобы увидеть команды" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "• skriv / för kommandon" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "• கட்டளைகளை காண / என்பதைத் தட்டச்சுங்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "• พิมพ์ / เพื่อแสดงคำสั่ง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "• komutlar için / yazın" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "• введи /, щоб побачити команди" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "• کمانڈز کیلئے / ٹائپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "• gõ / để xem lệnh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "• 输入 / 查看指令" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "• 輸入 / 查看指令" } } } }, "app_info.how_to_use.open_sidebar" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "• اضغط أيقونة الأشخاص لفتح الشريط الجانبي" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "• সাইডবার খুলতে মানুষ আইকনে ট্যাপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "• tippe auf das personen-icon, um die seitenleiste zu öffnen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "• tap people icon for sidebar" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "• toca el ícono de personas para abrir la barra lateral" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "• i-tap ang icon ng tao para buksan ang sidebar" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "• tape sur l'icône personnes pour ouvrir la barre latérale" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "• הקש על אייקון האנשים כדי לפתוח סרגל צד" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "• साइडबार खोलने के लिए लोगों वाले आइकन पर टैप करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "• ketuk ikon orang untuk membuka sidebar" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "• tocca l'icona persone per aprire la barra laterale" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "• 人アイコンをタップしてサイドバーを開く" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "• 사람 아이콘을 탭하여 사이드바를 엽니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "• ketuk ikon orang untuk membuka sidebar" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "• साइडबार खोल्न मान्छे आइकन ट्याप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "• tik op het personen-icoon om de zijbalk te openen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "• stuknij ikonę osób, aby otworzyć panel boczny" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "• toca no ícone das pessoas para abrir a barra lateral" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "• toque o ícone de pessoas para abrir a barra lateral" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "• нажми на иконку людей, чтобы открыть боковое меню" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "• tryck på personikonen för att öppna sidomenyn" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "• பக்கப்பட்டியைத் திறக்க மனிதர் சின்னத்தைத் தட்டுங்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "• แตะไอคอนคนเพื่อเปิดแถบด้านข้าง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "• kenar çubuğunu açmak için insan simgesine dokunun" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "• торкни піктограму людей, щоб відкрити бічну панель" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "• سائیڈ بار کھولنے کیلئے لوگوں کا آئیکن ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "• chạm biểu tượng người để mở thanh bên" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "• 轻点人物图标打开侧栏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "• 輕點人物圖示打開側欄" } } } }, "app_info.how_to_use.set_nickname" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "• اضبط لقبك بلمسه" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "• আপনার ডাকনামে ট্যাপ করে সেট করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "• tippe auf deinen nickname, um ihn zu ändern" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "• set your nickname by tapping it" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "• define tu apodo tocándolo" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "• itakda ang iyong palayaw sa pag-tap dito" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "• règle ton pseudo en le touchant" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "• הקש על הכינוי שלך כדי לעדכן" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "• अपना उपनाम टैप करके सेट करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "• atur nama panggilanmu dengan mengetuknya" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "• imposta il tuo nickname toccandolo" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "• ニックネームをタップして設定" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "• 닉네임을 탭하여 설정합니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "• atur nama panggilanmu dengan mengetuknya" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "• आफ्नो उपनाममा ट्याप गरेर मिलाऊ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "• stel je bijnaam in door erop te tikken" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "• ustaw swój pseudonim, stukając go" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "• define o teu apelido tocando nele" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "• defina seu apelido tocando nele" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "• коснись своего ника, чтобы изменить его" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "• ställ in ditt smeknamn genom att trycka på det" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "• உங்கள் புனைப் பெயரைத் தட்டிக் கொண்டு அமைக்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "• ตั้งชื่อเล่นโดยแตะที่ชื่อของคุณ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "• takma adınızı üzerine dokunarak ayarlayın" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "• змінюй свій нік, торкаючись його" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "• اپنا نک نیم سیٹ کرنے کیلئے اس پر ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "• đặt biệt danh bằng cách chạm vào tên bạn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "• 轻点昵称即可设置" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "• 輕點暱稱即可設定" } } } }, "app_info.how_to_use.start_dm" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "• اضغط اسم القرين لبدء رسائل خاصة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "• ডিএম শুরু করতে পিয়ারের নাম ট্যাপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "• tippe auf den namen eines peers, um eine pn zu starten" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "• tap a peer's name to start a DM" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "• toca el nombre de un participante para iniciar un MD" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "• i-tap ang pangalan ng peer para magsimula ng DM" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "• tape sur le nom d'un pair pour démarrer un mp" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "• הקש על שם עמית כדי להתחיל הודעה פרטית" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "• किसी पीयर का नाम टैप करके DM शुरू करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "• ketuk nama peer untuk mulai dm" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "• tocca il nome di un peer per avviare un dm" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "• ピアの名前をタップしてdm開始" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "• 피어의 이름을 탭하여 DM을 시작합니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "• ketuk nama peer untuk mulai dm" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "• dm सुरु गर्न कुनै सहकर्मीको नाम ट्याप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "• tik op de naam van een peer om een DM te starten" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "• stuknij nazwę peera, aby rozpocząć DM" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "• toca no nome de um par para iniciar um DM" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "• toque o nome de um par para iniciar um dm" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "• нажми имя пользователя, чтобы начать лс" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "• tryck på en peers namn för att starta ett DM" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "• peer பெயரைத் தட்டி DM தொடங்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "• แตะชื่อเพียร์เพื่อเริ่ม DM" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "• bir eşin adına dokunarak DM başlatın" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "• торкни ім'я піра, щоб почати приватний чат" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "• DM شروع کرنے کیلئے کسی ہم منصب کے نام پر ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "• chạm tên một nút ngang hàng để mở DM" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "• 轻点同伴名字开始 dm" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "• 輕點同伴名字開始 dm" } } } }, "app_info.how_to_use.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "طريقة الاستخدام" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্যবহারের নিয়ম" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "SO FUNKTIONIERT'S" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "HOW TO USE" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "CÓMO USARLO" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "PAANO GAMITIN" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "MODE D'EMPLOI" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "איך להשתמש" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "उपयोग कैसे करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "CARA PAKAI" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "COME SI USA" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "使い方" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "사용 방법" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "CARA PAKAI" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रयोग गर्ने तरिका" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "GEBRUIKSAANWIJZING" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "JAK UŻYWAĆ" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "COMO USAR" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "COMO USAR" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "КАК ИСПОЛЬЗОВАТЬ" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "SÅ HÄR ANVÄNDER DU" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பயன்படுத்துவது எப்படி" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "วิธีใช้งาน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "NASIL KULLANILIR" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "ЯК КОРИСТУВАТИСЯ" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "استعمال کرنے کا طریقہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "CÁCH SỬ DỤNG" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "使用方法" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "使用方法" } } } }, "app_info.privacy.ephemeral.description" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "يُولد معرف قرين جديد بانتظام" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "নিয়মিত নতুন পিয়ার আইডি তৈরি হয়" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "neue peer-id wird regelmäßig erzeugt" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "new peer ID generated regularly" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "nuevo ID de peer generado periódicamente" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "regular na lumilikha ng bagong peer ID" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "nouvel id de pair généré régulièrement" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מזהה עמית חדש נוצר באופן קבוע" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "नया पीयर आईडी नियमित रूप से बनाया जाता है" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "id peer baru dibuat secara berkala" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "nuovo id peer generato regolarmente" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "新しいpeer idが定期的に生成されます" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "새로운 피어 ID가 주기적으로 생성됩니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "id peer baru dibuat secara berkala" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "नयाँ peer id नियमित रूपमा सिर्जना हुन्छ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "nieuw peer-ID wordt regelmatig aangemaakt" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nowe ID peera generowane regularnie" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "novo ID de par gerado regularmente" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "novo id de peer gerado regularmente" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "новый id пира создаётся регулярно" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "nytt peer-ID skapas regelbundet" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "புதிய peer ID வழக்கமாக உருவாக்கப்படுகிறது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "สร้างรหัสเพียร์ใหม่เป็นระยะ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "yeni eş kimliği düzenli olarak oluşturulur" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "новий id піра генерується регулярно" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نیا peer ID باقاعدگی سے بنایا جاتا ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "tạo ID nút mới định kỳ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "定期生成新的 peer id" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "定期生成新的 peer id" } } } }, "app_info.privacy.ephemeral.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "هوية مؤقتة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "অস্থায়ী পরিচয়" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "flüchtige identität" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "ephemeral identity" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "identidad efímera" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "panandaliang pagkakakilanlan" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "identité éphémère" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "זהות זמנית" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "अस्थायी पहचान" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "identitas sementara" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "identità effimera" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "一時的なアイデンティティ" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "임시 신원" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "identitas sementara" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "क्षणिक पहिचान" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "tijdelijke identiteit" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "tymczasowa tożsamość" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "identidade efémera" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "identidade efêmera" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "эфемерная личность" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "tillfällig identitet" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "தற்காலிக அடையாளம்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ตัวตนชั่วคราว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "geçici kimlik" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "ефемерна ідентичність" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "عارضی شناخت" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "danh tính tạm thời" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "临时身份" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "臨時身份" } } } }, "app_info.privacy.no_tracking.description" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لا خوادم أو حسابات أو جمع بيانات" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "কোনো সার্ভার, অ্যাকাউন্ট বা ডেটা সংগ্রহ নেই" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "keine server, konten oder datensammlung" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "no servers, accounts, or data collection" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "sin servidores, cuentas ni recopilación de datos" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "walang mga server, account, o pagkuha ng data" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "sans serveurs, comptes ni collecte de données" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אין שרתים, חשבונות או איסוף נתונים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "कोई सर्वर, खाते या डेटा संग्रह नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tanpa server, akun, atau pengumpulan data" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "niente server, account o raccolta dati" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "サーバーもアカウントもデータ収集もなし" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "서버와 계정이 없으며 데이터를 수집하지 않습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tanpa server, akun, atau pengumpulan data" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सर्भर, खाताहरू वा तथ्याङ्क सङ्कलन छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "geen servers, accounts of dataverzameling" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "bez serwerów, kont ani zbierania danych" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "sem servidores, contas ou recolha de dados" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "sem servidores, contas ou coleta de dados" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "без серверов, аккаунтов и сбора данных" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "inga servrar, konton eller datainsamling" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சர்வர்கள் இல்லை, கணக்குகள் இல்லை, தரவுச் சேகரிப்பு இல்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่มีเซิร์ฟเวอร์ บัญชี หรือการเก็บข้อมูล" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "sunucu, hesap veya veri toplama yok" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "жодних серверів, обліковок чи збору даних" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "کوئی سرور، اکاؤنٹس یا ڈیٹا جمع نہیں کیا جاتا" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không máy chủ, không tài khoản, không thu thập dữ liệu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无服务器、无账号、无数据收集" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無伺服器、無帳號、無數據收集" } } } }, "app_info.privacy.no_tracking.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لا تتبع" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ট্র্যাকিং নেই" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "kein tracking" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "no tracking" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "sin seguimiento" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "walang pagsubaybay" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "sans suivi" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "ללא מעקב" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "ट्रैकिंग नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tanpa pelacakan" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "senza tracciamento" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "追跡なし" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "추적 없음" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tanpa pelacakan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "ट्र्याकिङ छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "geen tracking" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "brak śledzenia" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "sem rastreio" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "sem rastreamento" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "без трекинга" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ingen spårning" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பின்தொடர்வு இல்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่มีการติดตาม" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "izleme yok" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "без відстеження" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ٹریسنگ نہیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không theo dõi" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无跟踪" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無跟蹤" } } } }, "app_info.privacy.panic.description" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "اضغط الشعار ثلاث مرات لمسح كل البيانات فوراً" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "লোগোতে তিনবার ট্যাপ করলে সঙ্গে সঙ্গে সব ডেটা মুছে যাবে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "tippe dreimal auf das logo, um alle daten sofort zu löschen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "triple-tap logo to instantly clear all data" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "toca el logotipo tres veces para borrar todos los datos al instante" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-tap ang logo nang tatlong beses para agad burahin ang lahat ng data" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "tape trois fois sur le logo pour tout effacer instantanément" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הקש על הלוגו שלוש פעמים למחיקת כל הנתונים מייד" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "लोगो पर तीन बार टैप करें और तुरंत सारा डेटा साफ़ करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "ketuk logo tiga kali untuk langsung menghapus semua data" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "tocca il logo tre volte per cancellare subito tutti i dati" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ロゴを3回タップすると全データを即削除" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "로고를 세 번 탭하여 모든 데이터 즉시 삭제할 수 있습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "ketuk logo tiga kali untuk langsung menghapus semua data" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "लगो तीन पटक ट्याप गर्दा सबै डाटा तुरुन्त मेटिन्छ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "tik drie keer op het logo om alle data direct te wissen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "stuknij logo trzy razy, aby natychmiast usunąć wszystkie dane" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "toca três vezes no logótipo para limpar todos os dados de imediato" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "toque o logo três vezes para limpar todos os dados instantaneamente" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "тройной тап по логотипу мгновенно очищает все данные" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "tryck tre gånger på logotypen för att radera all data direkt" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "எல்லா தரவையும் உடனடியாக நீக்க லோகோவை மூன்று முறைத் தட்டுங்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "แตะโลโก้สามครั้งเพื่อลบข้อมูลทั้งหมดทันที" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "logoya üç kez dokunun, tüm veriler anında silinsin" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "тричі торкни логотип, щоб миттєво стерти всі дані" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "تمام ڈیٹا فوری صاف کرنے کیلئے لوگو پر تین بار ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chạm logo ba lần để xóa toàn bộ dữ liệu ngay lập tức" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "三击标志立即清除全部数据" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "三擊標誌立即清除全部數據" } } } }, "app_info.privacy.panic.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "وضع الذعر" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "প্যানিক মোড" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "panikmodus" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "panic mode" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "modo pánico" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "panic mode" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "mode panique" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מצב בהלה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "पैनिक मोड" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "mode panik" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "modalità panico" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "パニックモード" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "패닉 모드" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "mode panik" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "घबराहट मोड" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "paniekmodus" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "tryb paniki" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "modo pânico" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "modo pânico" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "режим паники" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "panikläge" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பதற்ற நிலை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "โหมดฉุกเฉิน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "panik modu" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "режим паніки" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "پینک موڈ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chế độ khẩn cấp" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "紧急模式" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "緊急模式" } } } }, "app_info.privacy.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "خصوصية" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "গোপনীয়তা" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "PRIVATSPHÄRE" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "PRIVACY" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "PRIVACIDAD" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "PRIBASYA" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "CONFIDENTIALITÉ" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "פרטיות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "गोपनीयता" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "PRIVASI" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "PRIVACY" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "プライバシー" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "개인정보 보호" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "PRIVASI" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "गोपनीयता" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "PRIVACY" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "PRYWATNOŚĆ" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "PRIVACIDADE" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "PRIVACIDADE" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "КОНФИДЕНЦИАЛЬНОСТЬ" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "PRIVAT" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "தனியுரிமை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ความเป็นส่วนตัว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "GİZLİLİK" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "КОНФІДЕНЦІЙНІСТЬ" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "رازداری" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "QUYỀN RIÊNG TƯ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "隐私" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "隱私" } } } }, "app_info.tagline" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "sidegroupchat" } } } }, "app_info.warning.message" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "أمان الرسائل الخاصة لم يتم تدقيقه بالكامل بعد. لا تستخدمها في الحالات الحرجة حتى يختفي هذا التحذير." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্যক্তিগত বার্তার নিরাপত্তা এখনো সম্পূর্ণ অডিট হয়নি। এই সতর্কতা না থাকা পর্যন্ত জরুরি পরিস্থিতিতে ব্যবহার করবেন না।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "die sicherheit privater nachrichten wurde noch nicht vollständig geprüft. nutze sie nicht für kritische situationen, solange dieser hinweis erscheint." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "private message security has not yet been fully audited. do not use for critical situations until this warning disappears." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "la seguridad de los mensajes privados aún no ha sido auditada por completo. no lo uses en situaciones críticas hasta que este aviso desaparezca." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi pa ganap na na-audit ang seguridad ng pribadong mensahe. huwag gamitin para sa kritikal na sitwasyon hangga't hindi nawawala ang babalang ito." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "la sécurité des messages privés n'a pas encore été entièrement auditée. n'utilise pas pour des situations critiques tant que cet avertissement reste." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אבטחת ההודעות הפרטיות עדיין לא נבדקה במלואה. אל תשתמש למצבים קריטיים עד שהאזהרה תיעלם." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "निजी संदेश सुरक्षा का अभी पूरा ऑडिट नहीं हुआ है। यह चेतावनी हटने तक गंभीर स्थितियों में उपयोग न करें।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "keamanan pesan pribadi belum diaudit sepenuhnya. jangan dipakai untuk situasi kritis sampai peringatan ini hilang." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "la sicurezza dei messaggi privati non è stata ancora auditata completamente. non usarli in situazioni critiche finché questo avviso resta." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "プライベートメッセージの安全性はまだ完全に監査されていません。この警告が消えるまで重要な場面では使わないでください。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "비공개 메시지 보안은 아직 완전히 감사받지 않았습니다. 이 경고가 사라질 때까지 중요한 상황에서는 사용하지 마세요." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "keamanan pesan pribadi belum diaudit sepenuhnya. jangan diguna untuk situasi kritis sampai peringatan ini hilang." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "व्यक्तिगत सन्देशको सुरक्षा पूर्ण रूपमा अडिट भएको छैन। यो चेतावनी हट्दासम्म गम्भीर अवस्थामा प्रयोग नगर्नु।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "de beveiliging van privéberichten is nog niet volledig geaudit. gebruik dit niet in kritieke situaties totdat deze melding verdwijnt." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "bezpieczeństwo wiadomości prywatnych nie zostało jeszcze w pełni sprawdzone. nie używaj w sytuacjach krytycznych, dopóki to ostrzeżenie nie zniknie." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "a segurança das mensagens privadas ainda não foi totalmente auditada. não uses em situações críticas até este aviso desaparecer." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "a segurança das mensagens privadas ainda não foi totalmente auditada. não use em situações críticas até que este aviso desapareça." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "безопасность приватных сообщений ещё не прошла полный аудит. не используй для критичных случаев, пока предупреждение не исчезнет." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "säkerheten för privata meddelanden är ännu inte fullständigt granskad. använd inte i kritiska situationer förrän detta meddelande försvinner." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "தனிப்பட்ட செய்தி பாதுகாப்பு இன்னும் முழுமையாக ஆய்வு செய்யப்படவில்லை. இந்த எச்சரிக்கை மறையும் வரை முக்கிய அவசரங்களுக்கு பயன்படுத்தாதீர்கள்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ความปลอดภัยของข้อความส่วนตัวยังไม่ได้รับการตรวจสอบทั้งหมด อย่าใช้ในสถานการณ์วิกฤติจนกว่าคำเตือนนี้จะหายไป" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "özel mesaj güvenliği henüz tamamen denetlenmedi. bu uyarı kaybolana kadar kritik durumlarda kullanmayın." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "безпека приватних повідомлень ще не пройшла повний аудит. не використовуй для критичних ситуацій, поки це попередження не зникне." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نجی پیغامات کی سیکیورٹی کا ابھی مکمل آڈٹ نہیں ہوا۔ اس انتباہ کے ختم ہونے تک اسے اہم حالات میں استعمال نہ کریں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "bảo mật tin nhắn riêng tư vẫn chưa được kiểm toán đầy đủ. đừng dùng cho tình huống quan trọng cho tới khi cảnh báo này biến mất." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "私信安全尚未完全审计。在此警告消失前不要用于关键情境。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "私信安全尚未完全審計。在此警告消失前不要用於關鍵情境。" } } } }, "app_info.warning.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تحذير" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "সতর্কতা" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "WARNUNG" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "WARNING" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "ADVERTENCIA" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "BABALA" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "AVERTISSEMENT" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אזהרה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "चेतावनी" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "PERINGATAN" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "AVVISO" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "警告" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "경고" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "PERINGATAN" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "चेतावनी" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "WAARSCHUWING" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "OSTRZEŻENIE" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "AVISO" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "AVISO" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "ПРЕДУПРЕЖДЕНИЕ" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "VARNING" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "எச்சரிக்கை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "คำเตือน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "UYARI" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "ПОПЕРЕДЖЕННЯ" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "انتباہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "CẢNH BÁO" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "警告" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "警告" } } } }, "close" : { "comment" : "Button to dismiss fullscreen media viewer", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إغلاق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বন্ধ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "schließen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "close" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cerrar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "isara" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "fermer" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "סגור" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "बंद करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tutup" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "chiudi" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "閉じる" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "닫기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tutup" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "बन्द" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "sluiten" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zamknij" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "fechar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "fechar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "закрыть" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "stäng" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "மூடவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ปิด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kapat" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "закрити" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بند کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đóng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "关闭" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "關閉" } } } }, "common.cancel" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إلغاء" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বাতিল" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "abbrechen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "cancel" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cancelar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "kanselahin" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "annuler" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "ביטול" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "रद्द करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "batal" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "annulla" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "キャンセル" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "취소" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "batal" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "रद्द" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "annuleren" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "anuluj" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "cancelar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "cancelar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "отмена" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "avbryt" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "ரத்து" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ยกเลิก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "iptal" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "скасувати" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "منسوخ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "hủy" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "取消" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "取消" } } } }, "common.close" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إغلاق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বন্ধ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "schließen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "close" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cerrar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "isara" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "fermer" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "סגור" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "बंद करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tutup" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "chiudi" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "閉じる" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "닫기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tutup" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "बन्द" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "sluiten" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zamknij" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "fechar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "fechar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "закрыть" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "stäng" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "மூடு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ปิด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kapat" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "закрити" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بند کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đóng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "关闭" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "關閉" } } } }, "common.copy" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "نسخ" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "কপি করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "kopieren" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "copy" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "copiar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "kopyahin" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "copier" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "העתק" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "कॉपी करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "salin" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "copia" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "コピー" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "복사" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "salin" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रतिलिपि" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "kopiëren" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "kopiuj" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "copiar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "copiar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "копировать" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kopiera" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "நகலெடு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "คัดลอก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kopyala" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "скопіювати" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نقل کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "sao chép" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "复制" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "複製" } } } }, "common.ok" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "موافق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ঠিক আছে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "aceptar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "ठीक है" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "확인" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "ठिक" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சரி" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ตกลง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "TAMAM" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ٹھیک" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "ĐỒNG Ý" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "确定" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "確定" } } } }, "common.toggle.off" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إيقاف" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বন্ধ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "aus" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "off" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "desactivado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "patay" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "désactivé" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "כבוי" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "बंद" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "mati" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "spento" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "オフ" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "끄기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "mati" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "अफ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "uit" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wył." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "desligado" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "desligado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "выкл" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "av" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "ஆஃப்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ปิด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kapalı" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "вимк" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بند" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "tắt" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "关闭" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "關閉" } } } }, "common.toggle.on" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تشغيل" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "চালু" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "an" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "on" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "activado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "bukas" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "activé" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "פעיל" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "चालू" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "nyala" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "acceso" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "オン" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "켜기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "nyala" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "अन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "aan" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wł." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "ligado" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "ligado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "вкл" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "på" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "ஆன்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เปิด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "açık" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "увімк" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "چالو" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "bật" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "开启" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "開啟" } } } }, "common.unknown" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "غير معروف" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "অজানা" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "unbekannt" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "unknown" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "desconocido" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi alam" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "inconnu" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "לא ידוע" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "अज्ञात" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak diketahui" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "sconosciuto" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "不明" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "알 수 없음" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak diketahui" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "अज्ञात" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "onbekend" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nieznane" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "desconhecido" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "desconhecido" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "неизвестно" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "okänt" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அறியவில்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่ทราบ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bilinmiyor" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "невідомо" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نامعلوم" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không rõ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "未知" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "未知" } } } }, "content.accessibility.add_favorite" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إضافة إلى المفضلة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "প্রিয়তে যোগ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "zu favoriten hinzufügen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "add to favorites" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "agregar a favoritos" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "idagdag sa mga paborito" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "ajouter aux favoris" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הוסף למועדפים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "पसंदीदा में जोड़ें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tambah ke favorit" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "aggiungi ai preferiti" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "お気に入りに追加" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "즐겨찾기에 추가" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tambah ke favorit" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "मनपर्नेमा थप" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "aan favorieten toevoegen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "dodaj do ulubionych" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "adicionar aos favoritos" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "adicionar aos favoritos" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "добавить в избранное" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "lägg till i favoriter" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பிரியப்பட்டதில் சேர்க்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เพิ่มในรายการโปรด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "favorilere ekle" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "додати до вибраного" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "پسندیدہ میں شامل کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "thêm vào mục yêu thích" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "加入收藏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "加入收藏" } } } }, "content.accessibility.available_nostr" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "متاح عبر nostr" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "নোস্টরে উপলব্ধ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verfügbar über nostr" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "available via Nostr" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "disponible vía Nostr" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "magagamit sa Nostr" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "disponible via nostr" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "זמין דרך nostr" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "नोस्ट्र पर उपलब्ध" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tersedia melalui nostr" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "disponibile via nostr" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "nostrで利用可能" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "Nostr를 통해 사용 가능" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tersedia melalui nostr" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "nostr मार्फत उपलब्ध" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "beschikbaar via Nostr" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "dostępne przez Nostr" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "disponível através do Nostr" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "disponível via nostr" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "доступно через nostr" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "tillgänglig via Nostr" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "Nostr வழியாக கிடைக்கிறது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ใช้งานได้ผ่าน Nostr" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "Nostr üzerinden kullanılabilir" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "доступно через nostr" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "Nostr کے ذریعے دستیاب" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "có sẵn qua Nostr" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "通过 Nostr 可用" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "透過 Nostr 可用" } } } }, "content.accessibility.back_to_main_chat" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "عودة إلى الدردشة الرئيسية" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "মুখ্য চ্যাটে ফিরুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "zurück zum hauptchat" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "back to main chat" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "volver al chat principal" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "bumalik sa pangunahing chat" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "retour au chat principal" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "חזרה לצ'אט הראשי" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "मुख्य चैट पर वापस" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "kembali ke chat utama" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "torna alla chat principale" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "メインチャットに戻る" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "메인 대화로 돌아가기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "kembali ke chat utama" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "मुख्य च्याटमा फर्क" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "terug naar hoofdchat" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wróć do głównego czatu" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "voltar ao chat principal" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "voltar ao chat principal" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "назад в основной чат" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "tillbaka till huvudchatten" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "முதன்மை உரையாடலுக்கு திரும்பு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "กลับไปยังห้องแชทหลัก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "ana sohbete dön" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "назад до основного чату" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "مرکزی چیٹ پر واپس جائیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "quay lại phòng chat chính" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "返回主聊天" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "返回主聊天" } } } }, "content.accessibility.connected_mesh" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "متصل عبر mesh" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "মেশের মাধ্যমে সংযুক্ত" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verbunden über mesh" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "connected via mesh" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "conectado por mesh" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nakakonekta sa pamamagitan ng mesh" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "connecté via mesh" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מחובר דרך mesh" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "मेश से जुड़ा" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "terhubung lewat mesh" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "connesso tramite mesh" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "mesh経由で接続" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "메시를 통해 연결됨" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "terhubung lewat mesh" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "mesh मार्फत जडान" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "verbonden via mesh" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "połączono przez mesh" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "ligado por mesh" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "conectado por mesh" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "подключено через mesh" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ansluten via mesh" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "mesh மூலம் இணைக்கப்பட்டுள்ளது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เชื่อมต่อผ่านเครือข่าย mesh" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "mesh üzerinden bağlı" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "з'єднано через mesh" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "mesh کے ذریعے جڑا ہوا" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đang kết nối qua mesh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "通过 mesh 已连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "透過 mesh 已連線" } } } }, "content.accessibility.encryption_status" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "حالة التشفير: %@" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এনক্রিপশনের অবস্থা: %@" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verschlüsselungsstatus: %@" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "encryption status: %@" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "estado de cifrado: %@" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "estado ng pag-encrypt: %@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "état du chiffrement : %@" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מצב הצפנה: %@" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एन्क्रिप्शन स्थिति: %@" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "status enkripsi: %@" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "stato crittografia: %@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "暗号状態: %@" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "암호화 상태: %@" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "status enkripsi: %@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सङ्केतको अवस्था: %@" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "versleutelingsstatus: %@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "stan szyfrowania: %@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "estado da encriptação: %@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "status da criptografia: %@" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "статус шифрования: %@" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "krypteringsstatus: %@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறியாக்க நிலை: %@" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "สถานะการเข้ารหัส: %@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şifreleme durumu: %@" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "стан шифрування: %@" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "انکرپشن کی حالت: %@" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "trạng thái mã hóa: %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "加密状态:%@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "加密狀態:%@" } } } }, "content.accessibility.location_channels" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "قنوات الموقع" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "অবস্থান চ্যানেল" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "kanäle für standorte" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "location channels" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "canales de ubicación" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "mga channel batay sa lokasyon" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "canaux de localisation" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "ערוצי מיקום" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "लोकेशन चैनल" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "kanal lokasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "canali posizione" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ロケーションチャンネル" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "위치 채널" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "kanal lokasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "स्थान च्यानल" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "locatiekanalen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "kanały lokalizacyjne" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "canais de localização" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "canais de localização" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "каналы локации" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "platskanaler" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இட சேனல்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ช่องตามตำแหน่ง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "konum kanalları" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "канали локації" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "لوکیشن چینلز" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "kênh theo vị trí" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "位置频道" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "位置頻道" } } } }, "content.accessibility.location_notes" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "ملاحظات الموقع لهذا المكان" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এই স্থানের লোকেশন নোট" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "standortnotizen für diesen ort" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "location notes for this place" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "notas de ubicación de este lugar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "mga tala para sa lugar na ito" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "notes de localisation pour cet endroit" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הערות מיקום למקום הזה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "इस स्थान के लोकेशन नोट" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "catatan lokasi untuk tempat ini" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "note di posizione per questo posto" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "この場所のロケーションノート" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 장소의 위치 노트" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "catatan lokasi untuk tempat ini" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "यस ठाउँका स्थान नोटहरू" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "locatienotities voor deze plek" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "notatki lokalizacyjne dla tego miejsca" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "notas de localização deste lugar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "notas de localização deste lugar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "заметки для этого места" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "platsanteckningar för den här platsen" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இந்த இடத்திற்கான குறிப்புகள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "บันทึกตำแหน่งสำหรับสถานที่นี้" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bu yer için konum notları" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "замітки про це місце" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "اس جگہ کیلئے لوکیشن نوٹس" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "ghi chú vị trí cho nơi này" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "此位置的笔记" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "此位置的筆記" } } } }, "content.accessibility.open_unread_private_chat" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "فتح دردشة خاصة غير مقروءة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "না পড়া ব্যক্তিগত চ্যাট খুলুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "ungelesene privatnachricht öffnen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "open unread private chat" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "abrir chat privado sin leer" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "buksan ang hindi pa nababasang pribadong chat" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "ouvrir le chat privé non lu" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "פתח צ'אט פרטי שלא נקרא" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "अपठित निजी चैट खोलें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "buka chat pribadi belum dibaca" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "apri chat privata non letta" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "未読のプライベートチャットを開く" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "읽지 않은 비공개 대화 열기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "buka chat pribadi belum dibaca" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "नपढिएको निजी च्याट खोल" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "ongelezen privéchat openen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "otwórz nieprzeczytany czat prywatny" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "abrir chat privado não lido" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "abrir chat privado não lido" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "открыть непрочитанный приватный чат" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "öppna oläst privat chatt" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "படிக்காத தனியுரையாடலைத் திறக்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เปิดแชทส่วนตัวที่ยังไม่ได้อ่าน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "okunmamış özel sohbeti aç" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "відкрити непрочитаний приватний чат" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نہ پڑھی گئی نجی چیٹ کھولیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "mở chat riêng chưa đọc" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "打开未读私聊" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "打開未讀私聊" } } } }, "content.accessibility.people_count" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "few" : { "stringUnit" : { "state" : "translated", "value" : "%d أشخاص" } }, "many" : { "stringUnit" : { "state" : "translated", "value" : "%d شخص" } }, "one" : { "stringUnit" : { "state" : "translated", "value" : "%d شخص" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d شخص" } }, "two" : { "stringUnit" : { "state" : "translated", "value" : "%d شخصان" } }, "zero" : { "stringUnit" : { "state" : "translated", "value" : "%d أشخاص" } } } } } } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d person" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d personen" } } } } } } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d person" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d people" } } } } } } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d persona" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d personas" } } } } } } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d personne" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d personnes" } } } } } } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "many" : { "stringUnit" : { "state" : "translated", "value" : "%d אנשים" } }, "one" : { "stringUnit" : { "state" : "translated", "value" : "%d אדם" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d אנשים" } }, "two" : { "stringUnit" : { "state" : "translated", "value" : "%d אנשים" } } } } } } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d orang" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d orang" } } } } } } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d persona" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d persone" } } } } } } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d人" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d人" } } } } } } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "other" : { "stringUnit" : { "state" : "translated", "value" : "%d명" } } } } } } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d व्यक्ति" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d व्यक्तिहरू" } } } } } } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d pessoa" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d pessoas" } } } } } } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "few" : { "stringUnit" : { "state" : "translated", "value" : "%d человека" } }, "many" : { "stringUnit" : { "state" : "translated", "value" : "%d человек" } }, "one" : { "stringUnit" : { "state" : "translated", "value" : "%d человек" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d человека" } } } } } } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "few" : { "stringUnit" : { "state" : "translated", "value" : "%d людини" } }, "many" : { "stringUnit" : { "state" : "translated", "value" : "%d людей" } }, "one" : { "stringUnit" : { "state" : "translated", "value" : "%d людина" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d людини" } } } } } } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" }, "substitutions" : { "people" : { "argNum" : 1, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d 人" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d 人" } } } } } } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "%#@people@" } } } }, "content.accessibility.private_chat_header" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "دردشة خاصة مع %@" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%@-এর সঙ্গে ব্যক্তিগত চ্যাট" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "privatchat mit %@" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "private chat with %@" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "chat privado con %@" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "pribadong chat kasama si %@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "chat privé avec %@" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "צ'אט פרטי עם %@" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%@ के साथ निजी चैट" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "chat pribadi dengan %@" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "chat privata con %@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@とのプライベートチャット" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%@과(와) 비공개 대화" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "chat pribadi dengan %@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%@ सँग निजी च्याट" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "privéchat met %@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "czat prywatny z %@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "chat privado com %@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "chat privado com %@" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "приватный чат с %@" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "privat chatt med %@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%@ உடன் தனியுரையாடல்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "แชทส่วนตัวกับ %@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%@ ile özel sohbet" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "приватний чат з %@" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%@ کے ساتھ نجی چیٹ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chat riêng với %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "与 %@ 的私聊" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "與 %@ 的私聊" } } } }, "content.accessibility.reachable_mesh" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "قابل للوصول عبر mesh" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "মেশের মাধ্যমে পৌঁছানো সম্ভব" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "erreichbar über mesh" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "reachable via mesh" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "disponible por mesh" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "maabot sa pamamagitan ng mesh" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "joignable via mesh" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "זמין דרך mesh" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "मेश के माध्यम से पहुंच योग्य" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "dapat dijangkau lewat mesh" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "raggiungibile via mesh" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "meshで到達可能" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "메시를 통해 연결 가능" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "dapat dijangkau lewat mesh" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "mesh मार्फत पहुँचयोग्य" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bereikbaar via mesh" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "osiągalny przez mesh" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "acessível por mesh" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "alcançável por mesh" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "достижим через mesh" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "nåbar via mesh" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "mesh மூலம் அணுகலாம்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ติดต่อได้ผ่าน mesh" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "mesh üzerinden ulaşılabilir" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "досяжно через mesh" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "mesh کے ذریعے قابل رسائی" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "liên hệ được qua mesh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "可通过 mesh 到达" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "可透過 mesh 到達" } } } }, "content.accessibility.remove_favorite" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إزالة من المفضلة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "প্রিয় থেকে সরান" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "aus favoriten entfernen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "remove from favorites" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "quitar de favoritos" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "alisin sa mga paborito" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "retirer des favoris" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הסר מהמועדפים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "पसंदीदा से हटाएँ" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "hapus dari favorit" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "rimuovi dai preferiti" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "お気に入りから削除" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "즐겨찾기에서 제거" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "hapus dari favorit" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "मनपर्नेबाट हटाउ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "uit favorieten verwijderen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "usuń z ulubionych" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "remover dos favoritos" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "remover dos favoritos" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "убрать из избранного" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ta bort från favoriter" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பிரியப்பட்டதை நீக்கு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "นำออกจากรายการโปรด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "favorilerden kaldır" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "видалити з вибраного" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "پسندیدہ سے ہٹائیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "xóa khỏi yêu thích" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "移出收藏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "移出收藏" } } } }, "content.accessibility.send_hint_empty" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "أدخل رسالة للإرسال" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বার্তা পাঠাতে একটি বার্তা লিখুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "gib eine nachricht zum senden ein" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "enter a message to send" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "introduce un mensaje para enviarlo" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "mag-type ng mensahe para ipadala" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "saisis un message à envoyer" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הזן הודעה לשליחה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "संदेश भेजने के लिए संदेश लिखें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "masukkan pesan untuk dikirim" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "inserisci un messaggio da inviare" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "送信するメッセージを入力" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "전송할 메시지를 입력하세요" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "masukkan pesan untuk dihantar" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "पठाउन सन्देश लेख" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "voer een bericht in om te sturen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wpisz wiadomość, aby wysłać" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "escreve uma mensagem para enviar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "digite uma mensagem para enviar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "введи сообщение для отправки" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "skriv ett meddelande för att skicka" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அனுப்ப உரையை உள்ளிடவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "พิมพ์ข้อความเพื่อส่ง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bir mesaj göndermek için metin girin" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "введи повідомлення для надсилання" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بھیجنے کیلئے پیغام درج کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "nhập tin nhắn để gửi" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "输入要发送的消息" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "輸入要發送的訊息" } } } }, "content.accessibility.send_hint_ready" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "اضغط مرتين للإرسال" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "পাঠাতে দুইবার ট্যাপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "doppelt tippen zum senden" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "double tap to send" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "toca dos veces para enviar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-double tap para magpadala" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "tape deux fois pour envoyer" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הקש פעמיים לשליחה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "भेजने के लिए डबल टैप करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "ketuk dua kali untuk mengirim" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "tocca due volte per inviare" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ダブルタップで送信" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "전송하려면 두 번 탭하세요" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "ketuk dua kali untuk menghantar" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "पठाउन दोहोरो ट्याप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "dubbelklikken om te versturen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "stuknij dwukrotnie, aby wysłać" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "toca duas vezes para enviar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "toque duas vezes para enviar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "дважды тапни, чтобы отправить" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "dubbeltryck för att skicka" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அனுப்ப இருமுறை தட்டவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "แตะสองครั้งเพื่อส่ง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "göndermek için çift dokunun" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "торкни двічі, щоб надіслати" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بھیجنے کیلئے دو بار ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chạm hai lần để gửi" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "双击发送" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "雙擊發送" } } } }, "content.accessibility.send_message" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إرسال رسالة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বার্তা পাঠান" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "nachricht senden" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "send message" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "enviar mensaje" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "magpadala ng mensahe" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "envoyer le message" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "שלח הודעה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "संदेश भेजें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "kirim pesan" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "invia messaggio" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "メッセージ送信" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "메시지 보내기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "hantar pesan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सन्देश पठाउ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bericht sturen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wyślij wiadomość" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "enviar mensagem" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "enviar mensagem" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "отправить сообщение" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "skicka meddelande" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "செய்தி அனுப்பு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ส่งข้อความ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "mesaj gönder" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "надіслати повідомлення" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "پیغام بھیجیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "gửi tin nhắn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "发送消息" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "發送訊息" } } } }, "content.accessibility.toggle_bookmark" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تبديل الإشارة لـ #%@" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "#%@-এর জন্য বুকমার্ক টগল করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "bookmark für #%@ umschalten" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "toggle bookmark for #%@" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "alternar marcador para #%@" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-toggle ang bookmark para sa #%@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "basculer le favori pour #%@" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "החלף סימנייה עבור #%@" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "#%@ के लिए बुकमार्क टॉगल करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "ubah penanda untuk #%@" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "cambia segnalibro per #%@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "#%@のブックマークを切り替え" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "#%@ 북마크 토글" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "ubah penanda untuk #%@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "#%@ का लागि बुकमार्क बदल" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bladwijzer voor #%@ schakelen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "przełącz zakładkę dla #%@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "alternar o marcador de #%@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "alternar favorito para #%@" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "переключить закладку для #%@" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "växla bokmärke för #%@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "#%@ க்கான புக்மார்க் மாற்றவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "สลับบุ๊กมาร์กสำหรับ #%@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "#%@ için yer işaretini değiştir" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "перемкнути закладку для #%@" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "#%@ کیلئے بُک مارک تبدیل کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chuyển đánh dấu cho #%@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "切换 #%@ 的书签" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "切換 #%@ 的書簽" } } } }, "content.accessibility.toggle_favorite_hint" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "اضغط مرتين لتبديل حالة المفضلة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "প্রিয় অবস্থা বদলাতে দুইবার ট্যাপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "doppelt tippen, um favoritenstatus zu wechseln" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "double tap to toggle favorite status" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "toca dos veces para alternar el estado de favorito" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-double tap para i-toggle ang paborito" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "tape deux fois pour basculer le statut favori" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הקש פעמיים כדי להחליף מצב מועדפים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "पसंदीदा स्थिति बदलने के लिए डबल टैप करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "ketuk dua kali untuk mengubah status favorit" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "tocca due volte per cambiare stato preferito" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ダブルタップでお気に入り状態を切り替え" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "두 번 탭하여 즐겨찾기 상태 토글" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "ketuk dua kali untuk mengubah status favorit" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "मनपर्ने स्थिति बदल्न दोहोरो ट्याप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "dubbelklikken om favoriet te schakelen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "stuknij dwukrotnie, aby zmienić stan ulubionych" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "toca duas vezes para alternar o estado de favorito" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "toque duas vezes para alternar status de favorito" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "дважды тапни, чтобы переключить статус избранного" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "dubbeltryck för att växla favoritstatus" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பிரியப்பட்ட நிலையை மாற்ற இருமுறை தட்டவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "แตะสองครั้งเพื่อสลับสถานะรายการโปรด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "favori durumunu değiştirmek için çift dokunun" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "торкни двічі, щоб змінити статус вибраного" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "پسندیدہ حالت بدلنے کیلئے دو بار ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chạm hai lần để chuyển trạng thái yêu thích" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "双击切换收藏状态" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "雙擊切換收藏狀態" } } } }, "content.accessibility.view_fingerprint_hint" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "اضغط لمشاهدة بصمة التشفير" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এনক্রিপশন ফিঙ্গারপ্রিন্ট দেখতে ট্যাপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "tippe, um den verschlüsselungs-fingerprint zu sehen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "tap to view encryption fingerprint" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "toca para ver la huella de cifrado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-tap para makita ang fingerprint ng pag-encrypt" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "tape pour voir l'empreinte de chiffrement" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הקש להצגת טביעת ההצפנה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एन्क्रिप्शन फिंगरप्रिंट देखने के लिए टैप करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "ketuk untuk melihat sidik enkripsi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "tocca per vedere l'impronta di cifratura" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "暗号フィンガープリントを見る" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "탭하여 암호화 지문 보기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "ketuk untuk melihat sidik enkripsi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सङ्केत फिङ्गरप्रिन्ट हेर्न ट्याप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "tik om de versleutelingsfingerprint te bekijken" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "stuknij, by zobaczyć odcisk szyfrowania" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "toca para ver a impressão de encriptação" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "toque para ver a impressão de criptografia" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "нажми, чтобы увидеть криптографический отпечаток" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "tryck för att visa krypteringsfingeravtryck" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறியாக்க கைரேகையைப் பார்க்க தட்டவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "แตะเพื่อดูลายนิ้วมือการเข้ารหัส" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şifreleme parmak izini görmek için dokunun" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "торкни, щоб переглянути криптографічний відбиток" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "انکرپشن فنگرپرنٹ دیکھنے کیلئے ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chạm để xem vân tay mã hóa" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "轻点查看加密指纹" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "輕點查看加密指紋" } } } }, "content.actions.block" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "حظر" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্লক করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "blockieren" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "block" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "bloquear" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-block" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "bloquer" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "חסום" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "ब्लॉक करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "blokir" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "blocca" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ブロック" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "차단하기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "blokir" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "ब्लक" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "blokkeren" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zablokuj" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "bloquear" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "bloquear" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "заблокировать" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "blockera" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "தடுப்பு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "บล็อก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "engelle" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "заблокувати" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بلاک کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chặn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "屏蔽" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "屏蔽" } } } }, "content.actions.direct_message" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "رسالة مباشرة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ডিরেক্ট মেসেজ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "direktnachricht" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "direct message" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "mensaje directo" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "diretsong mensahe" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "message direct" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הודעה ישירה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "डायरेक्ट संदेश" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "pesan langsung" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "messaggio diretto" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ダイレクトメッセージ" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "다이렉트 메시지 보내기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "pesan langsung" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रत्यक्ष सन्देश" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "direct bericht" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wiadomość prywatna" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "mensagem direta" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "mensagem direta" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "личное сообщение" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "direktmeddelande" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "நேரடி செய்தி" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ส่งข้อความส่วนตัว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "doğrudan mesaj" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "приватне повідомлення" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "براہ راست پیغام" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "nhắn tin trực tiếp" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "私信" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "私信" } } } }, "content.actions.hug" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "عناق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "আলিঙ্গন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "umarmen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "hug" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "abrazo" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "yakap" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "câlin" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "חיבוק" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "गले लगाएँ" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "peluk" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "abbraccia" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ハグ" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "포옹하기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "peluk" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "अँगालो" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "knuffel" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "przytul" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "abraçar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "abraço" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "обнять" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kram" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அடைகாக்க" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "กอด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "sarıl" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "обійняти" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "گلے لگائیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "ôm" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "拥抱" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "擁抱" } } } }, "content.actions.mention" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "ذكر" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "মেনশন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "erwähnen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "mention" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "mencionar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "banggitin" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "mentionner" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אזכור" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "मेंशन करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "sebut" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "menziona" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "メンション" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "멘션하기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "sebut" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "उल्लेख" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "vermelden" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wspomnij" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "mencionar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "mencionar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "упомянуть" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "nämn" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "மேற்கோள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "กล่าวถึง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bahset" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "згадати" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ذکر کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "nhắc tới" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "提及" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "提及" } } } }, "content.actions.slap" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "صفعة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "চপেটাঘাত" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "ohrfeige" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "slap" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "bofetada" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "sampal" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "gifle" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "סטירה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "थप्पड़ मारें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tampar" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "schiaffo" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ビンタ" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "툭 치기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tampar" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "थप्पड" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "meppen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "spoliczkuj" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "dar uma chapada" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "tapa" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "дать леща" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ge en örfil" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அடி" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ตบ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "tokatla" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "ляпас" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "چاپڑ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "tát" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "拍打" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "拍打" } } } }, "content.actions.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إجراءات" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "অ্যাকশন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "aktionen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "actions" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "acciones" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "mga aksyon" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "actions" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "פעולות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "क्रियाएँ" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "aksi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "azioni" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクション" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "작업" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "aksi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "कार्य" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "acties" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "akcje" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "ações" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "ações" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "действия" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "åtgärder" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "செயல்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "การกระทำ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "eylemler" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "дії" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "کارروائیاں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "hành động" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "操作" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "操作" } } } }, "content.alert.bluetooth_required.off" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth متوقف. فعّل bluetooth في الإعدادات لاستخدام bitchat." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্লুটুথ বন্ধ আছে। bitchat ব্যবহার করতে সেটিংসে ব্লুটুথ চালু করুন।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth ist ausgeschaltet. aktiviere bluetooth in den einstellungen, um bitchat zu verwenden." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth is turned off. please turn on bluetooth in settings to use bitchat." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth está desactivado. Actívalo en Ajustes para usar BitChat." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "naka-off ang bluetooth. pakibuksan sa settings para magamit ang bitchat." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth est désactivé. active le bluetooth dans réglages pour utiliser bitchat." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth כבוי. הפעל bluetooth בהגדרות כדי להשתמש ב-bitchat." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "ब्लूटूथ बंद है। bitchat उपयोग करने के लिए सेटिंग्स में ब्लूटूथ चालू करें।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth dimatikan. aktifkan bluetooth di pengaturan untuk memakai bitchat." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth è disattivato. attiva bluetooth nelle impostazioni per usare bitchat." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "bluetoothがオフです。設定でbluetoothをオンにしてbitchatを使ってください。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth가 꺼져 있습니다. bitchat을 사용하려면 설정에서 bluetooth를 켜주세요." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth dimatikan. Hidupkan Bluetooth dalam Tetapan untuk menggunakan bitchat." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth बन्द छ। bitchat प्रयोग गर्न bluetooth सेटिङमा अन गर।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth staat uit. zet bluetooth aan in instellingen om bitchat te gebruiken." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth jest wyłączony. włącz bluetooth w ustawieniach, aby używać bitchat." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "O Bluetooth está desligado. Ativa o Bluetooth em Definições para usar o bitchat." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth está desligado. ative o bluetooth em ajustes para usar bitchat." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth выключен. включи bluetooth в настройках, чтобы использовать bitchat." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth är avstängt. slå på bluetooth i inställningar för att använda bitchat." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth அணைக்கப்பட்டுள்ளது. bitchat பயன்படுத்த அமைப்பில் Bluetooth ஐ இயக்கவும்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ปิด Bluetooth อยู่ กรุณาเปิดในตั้งค่าเพื่อใช้ bitchat" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth kapalı. bitchat'i kullanmak için ayarlardan Bluetooth'u açın." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth вимкнений. увімкни bluetooth у налаштуваннях, щоб користуватися bitchat." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth بند ہے۔ bitchat استعمال کرنے کیلئے سیٹنگز میں Bluetooth آن کریں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth đang tắt. hãy bật trong cài đặt để dùng bitchat." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth 已关闭。请在设置中开启以使用 bitchat。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth 已關閉。請在設定中開啟以使用 bitchat。" } } } }, "content.alert.bluetooth_required.permission" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تحتاج bitchat إلى إذن bluetooth للاتصال بالأجهزة القريبة. فعّل الوصول في الإعدادات." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "নিকটবর্তী ডিভাইসের সাথে যুক্ত হতে bitchat-এর ব্লুটুথ অনুমতি দরকার। দয়া করে সেটিংসে ব্লুটুথ অ্যাক্সেস সক্রিয় করুন।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "bitchat benötigt bluetooth-berechtigung, um sich mit geräten in der nähe zu verbinden. erlaube den zugriff in den einstellungen." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "bitchat needs bluetooth permission to connect with nearby devices. please enable bluetooth access in settings." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "bitChat necesita permiso de Bluetooth para conectarse con dispositivos cercanos. Habilita el acceso en Ajustes." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "kailangan ng bitchat ng pahintulot sa bluetooth upang kumonekta sa mga kalapit na device. pakiactivate sa settings." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "bitchat a besoin de l'autorisation bluetooth pour se connecter aux appareils proches. active l'accès dans réglages." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "bitchat צריכה הרשאת bluetooth כדי להתחבר למכשירים קרובים. אפשר גישה בהגדרות." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "bitchat को पास के उपकरणों से जुड़ने के लिए ब्लूटूथ अनुमति चाहिए। कृपया सेटिंग्स में ब्लूटूथ एक्सेस सक्षम करें।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "bitchat memerlukan izin bluetooth untuk terhubung dengan perangkat dekat. aktifkan akses di pengaturan." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "bitchat richiede l'autorizzazione bluetooth per collegarsi ai dispositivi vicini. abilita l'accesso nelle impostazioni." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "bitchatは近くのデバイスと接続するためbluetooth権限が必要です。設定でアクセスを有効にしてください。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "bitchat은 주변 기기와 연결하기 위해 bluetooth 권한이 필요합니다. 설정에서 bluetooth 접근을 활성화해주세요." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "bitchat memerlukan kebenaran Bluetooth untuk disambungkan kepada peranti berhampiran. Benarkan capaian dalam Tetapan." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "bitchat लाई नजिकका उपकरणसँग जडान हुन bluetooth अनुमति चाहिन्छ। सेटिङमा पहुँच सक्षम गर।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bitchat heeft bluetooth-toegang nodig om verbinding te maken met apparaten in de buurt. schakel dit in bij instellingen." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "bitchat potrzebuje uprawnień bluetooth, aby łączyć się z pobliskimi urządzeniami. włącz dostęp w ustawieniach." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "O bitchat precisa de autorização de Bluetooth para se ligar a dispositivos próximos. Ativa o acesso em Definições." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "bitchat precisa de permissão de bluetooth para conectar com dispositivos próximos. habilite o acesso em ajustes." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "bitchat нужен доступ к bluetooth, чтобы соединяться с ближайшими устройствами. включи разрешение в настройках." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "bitchat behöver bluetooth-behörighet för att ansluta till enheter i närheten. aktivera åtkomst i inställningar." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அருகிலுள்ள சாதனங்களை இணைக்க bitchat க்கு Bluetooth அனுமதி தேவை. அமைப்பில் அனுமதி வழங்கவும்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "bitchat ต้องการสิทธิ์ Bluetooth เพื่อเชื่อมต่อกับอุปกรณ์ใกล้เคียง กรุณาเปิดสิทธิ์ในตั้งค่า" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bitchat'in yakın cihazlara bağlanması için Bluetooth izni gerekir. lütfen ayarlardan Bluetooth erişimini etkinleştirin." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "bitchat потребує дозволу bluetooth для з'єднання з пристроями поруч. ввімкни доступ у налаштуваннях." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "قریب کے آلات سے جڑنے کیلئے bitchat کو Bluetooth اجازت درکار ہے۔ سیٹنگز میں رسائی فعال کریں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "bitchat cần quyền bluetooth để kết nối thiết bị gần. hãy bật quyền trong cài đặt." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "bitchat 需要 bluetooth 权限以连接附近设备。请在设置中启用访问。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "bitchat 需要 bluetooth 權限以連線至附近裝置。請在設定中啟用存取權限。" } } } }, "content.alert.bluetooth_required.settings" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "الإعدادات" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "সেটিংস" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "einstellungen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "settings" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "ajustes" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "settings" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "réglages" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הגדרות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "सेटिंग्स" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "pengaturan" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "impostazioni" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "設定" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "설정" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "Tetapan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सेटिङ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "instellingen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "ustawienia" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "definições" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "ajustes" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "настройки" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "inställningar" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அமைப்புகள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ตั้งค่า" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "ayarlar" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "налаштування" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "سیٹنگز" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "cài đặt" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "设置" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "設定" } } } }, "content.alert.bluetooth_required.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مطلوب bluetooth" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্লুটুথ প্রয়োজন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth erforderlich" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth required" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "se requiere Bluetooth" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "kailangan ng bluetooth" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth requis" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "נדרש bluetooth" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "ब्लूटूथ आवश्यक" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "butuh bluetooth" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "serve bluetooth" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "bluetoothが必要" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth 필요" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth diperlukan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth आवश्यक" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth vereist" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wymagany bluetooth" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth necessário" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth necessário" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth обязателен" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "bluetooth krävs" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth தேவையானது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ต้องใช้ Bluetooth" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth gerekli" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "потрібен bluetooth" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth درکار ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "cần bluetooth" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "需要 bluetooth" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "需要 bluetooth" } } } }, "content.alert.bluetooth_required.unsupported" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "هذا الجهاز لا يدعم bluetooth. يحتاج bitchat إلى bluetooth للعمل." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এই ডিভাইস ব্লুটুথ সমর্থন করে না। bitchat চালাতে ব্লুটুথ দরকার।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "dieses gerät unterstützt kein bluetooth. bitchat benötigt bluetooth zum funktionieren." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "this device does not support bluetooth. bitchat requires bluetooth to function." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "este dispositivo no admite Bluetooth. BitChat necesita Bluetooth para funcionar." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi sinusuportahan ng device na ito ang bluetooth. kailangan ito ng bitchat para gumana." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "cet appareil ne prend pas en charge le bluetooth. bitchat en a besoin pour fonctionner." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "המכשיר הזה לא תומך ב-bluetooth. bitchat זקוקה ל-bluetooth כדי לעבוד." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "यह डिवाइस ब्लूटूथ समर्थित नहीं है। bitchat चलाने के लिए ब्लूटूथ जरूरी है।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "perangkat ini tidak mendukung bluetooth. bitchat memerlukan bluetooth untuk berjalan." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "questo dispositivo non supporta bluetooth. bitchat richiede bluetooth per funzionare." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "このデバイスはbluetoothをサポートしていません。bitchatにはbluetoothが必要です。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 기기는 bluetooth를 지원하지 않습니다. bitchat이 작동하려면 bluetooth가 필요합니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "Peranti ini tidak menyokong Bluetooth. bitchat memerlukan Bluetooth untuk berfungsi." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "यो उपकरणले bluetooth समर्थन गर्दैन। bitchat चलाउन bluetooth चाहिन्छ।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "dit apparaat ondersteunt geen bluetooth. bitchat heeft bluetooth nodig om te werken." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "to urządzenie nie obsługuje bluetooth. bitchat wymaga bluetooth do działania." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "Este dispositivo não suporta Bluetooth. O bitchat precisa de Bluetooth para funcionar." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "este dispositivo não suporta bluetooth. bitchat precisa de bluetooth para funcionar." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "это устройство не поддерживает bluetooth. bitchat нужен bluetooth для работы." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "denna enhet stöder inte bluetooth. bitchat behöver bluetooth för att fungera." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இந்த சாதனம் Bluetooth ஐ ஆதரிக்காது. bitchat இயங்க Bluetooth தேவை." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "อุปกรณ์นี้ไม่รองรับ Bluetooth การใช้งาน bitchat ต้องใช้ Bluetooth" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bu cihaz Bluetooth'u desteklemiyor. bitchat'in çalışması için Bluetooth gerekli." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "цей пристрій не підтримує bluetooth. bitchat потрібен bluetooth для роботи." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "یہ ڈیوائس Bluetooth کو سپورٹ نہیں کرتی۔ bitchat کو چلنے کیلئے Bluetooth چاہیے۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "thiết bị này không hỗ trợ bluetooth. bitchat cần bluetooth để hoạt động." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "此设备不支持 bluetooth。bitchat 需要 bluetooth 才能运行。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "此裝置不支持 bluetooth。bitchat 需要 bluetooth 才能運行。" } } } }, "content.alert.screenshot.message" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لقطات قنوات الموقع تكشف موقعك. فكر قبل المشاركة علناً." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "লোকেশন চ্যানেলের স্ক্রিনশট আপনার অবস্থান প্রকাশ করবে। প্রকাশ করার আগে ভেবে দেখুন।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "screenshots von standortkanälen verraten deinen standort. überleg dir das teilen vorher gut." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "screenshots of location channels will reveal your location. think before sharing publicly." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "las capturas de pantalla de los canales de ubicación revelarán tu ubicación. Piensa antes de compartirlas públicamente." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "ibinubunyag ng screenshot ng mga channel ng lokasyon ang iyong lokasyon. mag-isip bago magbahagi sa publiko." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "les captures des canaux de localisation révéleront ta position. réfléchis avant de partager publiquement." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "צילומי מסך של ערוצי מיקום יחשפו את מיקומך. חשב לפני שיתוף פומבי." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "लोकेशन चैनलों के स्क्रीनशॉट आपकी लोकेशन प्रकट करेंगे। सार्वजनिक रूप से साझा करने से पहले सोचें।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tangkapan layar kanal lokasi akan mengungkap lokasimu. pikirkan dulu sebelum membagikannya." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "gli screenshot dei canali posizione rivelano la tua posizione. pensaci prima di condividerli." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ロケーションチャンネルのスクリーンショットはあなたの場所を明かします。公開前によく考えてください。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "위치 채널의 스크린샷은 사용자의 위치를 노출할 수 있습니다. 스크린샷을 공개적으로 공유할 때 주의해 주세요." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tangkapan layar kanal lokasi akan mengungkap lokasimu. pikirkan dulu sebelum membagikannya." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "स्थान च्यानलको स्क्रिनसटले तिम्रो स्थान खुलाउँछ। सार्वजनिकरूपमा बाँड्नु अघि सोच।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "screenshots van locatiekanalen geven je locatie prijs. denk na voor je publiek deelt." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zrzuty ekranu kanałów lokalizacyjnych ujawnią twoją lokalizację. przemyśl to przed publicznym udostępnieniem." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "Capturas de ecrã dos canais de localização revelam a tua localização. Pensa antes de partilhar publicamente." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "capturas de canais de localização revelam sua localização. pense antes de compartilhar publicamente." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "скриншоты каналов местоположения раскроют твою позицию. подумай, прежде чем делиться публично." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "skärmbilder från platskanaler avslöjar din position. tänk efter innan du delar offentligt." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இட சேனல்களின் திரைப் படங்கள் உங்கள் இடத்தை வெளிப்படுத்தும். பொதுவாகப் பகிர்வதற்கு முன் சிந்தியுங்கள்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "การจับภาพหน้าจอช่องตำแหน่งจะเผยตำแหน่งของคุณ โปรดคิดก่อนแชร์สาธารณะ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "konum kanallarının ekran görüntüleri konumunuzu ortaya çıkarır. paylaşmadan önce iki kez düşünün." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "скріншоти каналів локації розкриють твоє місце. подумай, перш ніж ділитися публічно." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "لوکیشن چینلز کے اسکرین شاٹس آپ کی لوکیشن ظاہر کر دیں گے۔ عوامی طور پر شیئر کرنے سے پہلے سوچیں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "ảnh chụp kênh vị trí sẽ lộ vị trí của bạn. hãy cân nhắc trước khi chia sẻ công khai." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "位置频道的截图会暴露你的位置。公开分享前请三思。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "位置頻道的截圖會暴露你的位置。公開分享前請三思。" } } } }, "content.alert.screenshot.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تنبيه" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "সতর্কতা" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "achtung" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "heads up" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "atención" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "paalala" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "attention" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "שים לב" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "ध्यान दें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "perhatian" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "attenzione" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "注意" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "알림" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "perhatian" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "ध्यान" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "let op" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "uwaga" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "atenção" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "atenção" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "внимание" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "obs" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "கவனம்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "แจ้งเตือน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "dikkat" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "увага" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "خبردار" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "lưu ý" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "注意" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "注意" } } } }, "content.commands.block" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "حظر أو عرض المحظورين" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্লক বা ব্লক করা পিয়ারদের তালিকা দেখুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "blocked peers anzeigen oder blockieren" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "block or list blocked peers" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "bloquear o listar usuarios bloqueados" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-block o tingnan ang listahan ng mga na-block na peer" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "bloquer ou lister les pairs bloqués" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "חסום או הצג עמיתים חסומים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "ब्लॉक करें या ब्लॉक पीयरों की सूची देखें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "blokir atau lihat peer yang diblokir" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "blocca o mostra i peer bloccati" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ブロックまたはブロック済みを表示" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "피어 차단 또는 차단된 피어 목록 보기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "blokir atau lihat peer yang diblokir" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "ब्लक गर वा ब्लक गरिएको सूची देखाउ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "blokkeer of toon lijst met geblokkeerde peers" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zablokuj albo pokaż listę zablokowanych peerów" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "bloquear ou listar pares bloqueados" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "bloquear ou listar pares bloqueados" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "заблокировать или показать заблокированных" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "blockera eller visa lista över blockerade peers" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "தடை செய்யவும் அல்லது தடை செய்யப்பட்ட peer பட்டியலைப் பாருங்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "บล็อกหรือดูรายชื่อเพียร์ที่ถูกบล็อก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "engelle veya engellenen eşleri listele" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "заблокувати або показати заблокованих" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "peer کو بلاک کریں یا فہرست دیکھیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chặn hoặc xem danh sách nút đã chặn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "屏蔽或查看已屏蔽的同伴" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "屏蔽或查看已屏蔽的同伴" } } } }, "content.commands.clear" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مسح رسائل الدردشة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "চ্যাট মেসেজ মুছে দিন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "chatnachrichten löschen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "clear chat messages" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "borrar los mensajes del chat" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "burahin ang mga mensahe sa chat" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "effacer les messages du chat" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "נקה הודעות צ'אט" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "चैट संदेश साफ़ करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "hapus pesan chat" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "svuota la chat" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "チャットをクリア" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "대화 메시지 지우기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "hapus pesan chat" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "च्याट सन्देश खाली गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "maak chatberichten leeg" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wyczyść wiadomości czatu" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "limpar mensagens do chat" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "limpar mensagens do chat" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "очистить чат" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "rensa chattmeddelanden" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "உரையாடல் செய்திகளை அழிக்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ล้างข้อความแชท" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "sohbet mesajlarını temizle" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "очистити чат" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "چیٹ پیغامات صاف کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "xóa tin nhắn chat" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "清除聊天消息" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "清除聊天訊息" } } } }, "content.commands.favorite" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إضافة للمفضلة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "প্রিয়তে যোগ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "zu favoriten hinzufügen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "add to favorites" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "agregar a favoritos" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "idagdag sa mga paborito" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "ajouter aux favoris" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הוסף למועדפים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "पसंदीदा में जोड़ें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tambah ke favorit" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "aggiungi ai preferiti" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "お気に入りに追加" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "즐겨찾기에 추가하기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tambah ke favorit" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "मनपर्नेमा थप" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "aan favorieten toevoegen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "dodaj do ulubionych" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "adicionar aos favoritos" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "adicionar aos favoritos" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "добавить в избранное" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "lägg till i favoriter" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பிரியப்பட்டதில் சேர்க்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เพิ่มในรายการโปรด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "favorilere ekle" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "додати до вибраного" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "پسندیدہ میں شامل کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "thêm vào yêu thích" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "加入收藏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "加入收藏" } } } }, "content.commands.hug" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إرسال عناق دافئ" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "কাউকে উষ্ণ আলিঙ্গন পাঠান" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "eine warme umarmung senden" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "send someone a warm hug" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "enviar un abrazo caluroso" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "magpadala ng mainit na yakap" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "envoyer un câlin chaleureux" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "שלח חיבוק חם" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "किसी को गर्म आलिंगन भेजें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "kirim pelukan hangat" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "invia un caldo abbraccio" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "あたたかいハグを送る" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "따뜻한 포옹 보내기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "hantar pelukan hangat" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "न्यानो अँगालो पठाउ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "stuur iemand een warme knuffel" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wyślij komuś ciepły uścisk" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "enviar um abraço caloroso" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "enviar um abraço quente" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "отправить тёплое объятие" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "skicka en varm kram" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "ஒருவருக்கு வெப்பமான அணைப்பை அனுப்பவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ส่งกอดอุ่น ๆ ให้ใครสักคน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "birine sıcak bir sarılma gönder" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "відправити теплі обійми" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "کسی کو گرمجوشی سے گلے لگائیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "gửi một cái ôm ấm áp" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "送出温暖拥抱" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "送出溫暖擁抱" } } } }, "content.commands.message" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إرسال رسالة خاصة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্যক্তিগত বার্তা পাঠান" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "privatnachricht senden" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "send private message" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "enviar mensaje privado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "magpadala ng pribadong mensahe" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "envoyer un message privé" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "שלח הודעה פרטית" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "निजी संदेश भेजें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "kirim pesan pribadi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "invia messaggio privato" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "プライベートメッセージを送る" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "비공개 메시지 보내기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "hantar pesan pribadi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "निजी सन्देश पठाउ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "stuur privébericht" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wyślij wiadomość prywatną" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "enviar mensagem privada" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "enviar mensagem privada" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "отправить приватное сообщение" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "skicka privat meddelande" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "தனியுரையாடல் அனுப்பவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ส่งข้อความส่วนตัว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "özel mesaj gönder" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "надіслати приватне повідомлення" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نجی پیغام بھیجیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "gửi tin nhắn riêng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "发送私信" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "發送私信" } } } }, "content.commands.slap" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "صفع شخص بسمكة تراوت" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "কারওকে ট্রাউট মাছ দিয়ে চপেটাঘাত করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "jemandem eine forelle um die ohren schlagen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "slap someone with a trout" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "abofetear a alguien con una trucha" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "sampalin ang isang tao gamit ang trout" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "gifler quelqu'un avec une truite" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "תן למישהו סטירת פורל" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "किसी को ट्राउट से थप्पड़ मारें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tampar seseorang dengan ikan trout" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "schiaffeggia qualcuno con una trota" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "誰かをトラウトでたたく" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "송어로 때리기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tampar seseorang dengan ikan trout" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "कसैलाई ट्राउटले थप्पड दे" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "geef iemand een mep met een forel" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "spoliczkuj kogoś trocią" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "dar uma chapada a alguém com uma truta" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "dar um tapa em alguém com uma truta" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "дать кому-то пощёчину форелью" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "smäll någon med en öring" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "ஒருவரை trout மீனால் அடிக்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ตบใครสักคนด้วยปลาเทราต์" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "birine alabalıkla tokat at" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "лупнути когось фореллю" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "کسی کو ٹراؤٹ سے تھپڑ ماریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "tát ai đó bằng cá hồi" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "用鳟鱼拍某人" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "用鱒魚拍某人" } } } }, "content.commands.unblock" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إلغاء حظر قرين" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "কাউকে আনব্লক করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "peer entsperren" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "unblock a peer" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "desbloquear a un usuario" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "alisin ang pag-block sa peer" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "débloquer un pair" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "בטל חסימה לעמית" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "किसी पीयर को अनब्लॉक करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "buka blokir peer" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "sblocca un peer" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ピアのブロックを解除" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "피어 차단 해제하기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "buka blokir peer" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "पीयर अनब्लक गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "peer deblokkeren" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "odblokuj peera" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "desbloquear um par" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "desbloquear um par" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "разблокировать пира" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "avblockera peer" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "peer தடை நீக்கு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เลิกบล็อกเพียร์" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bir eşin engelini kaldır" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "розблокувати піра" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "peer کا بلاک ہٹائیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "bỏ chặn nút" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "取消屏蔽同伴" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "取消屏蔽同伴" } } } }, "content.commands.unfavorite" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إزالة من المفضلة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "প্রিয় থেকে সরিয়ে দিন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "aus favoriten entfernen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "remove from favorites" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "quitar de favoritos" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "alisin sa mga paborito" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "retirer des favoris" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הסר מהמועדפים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "पसंदीदा से हटाएँ" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "hapus dari favorit" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "rimuovi dai preferiti" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "お気に入りから外す" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "즐겨찾기에서 제거하기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "hapus dari favorit" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "मनपर्नेबाट हटाउ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "uit favorieten verwijderen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "usuń z ulubionych" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "remover dos favoritos" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "remover dos favoritos" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "убрать из избранного" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ta bort från favoriter" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பிரியப்பட்டதில் இருந்து நீக்கு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "นำออกจากรายการโปรด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "favorilerden çıkar" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "видалити з вибраного" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "پسندیدہ سے ہٹا دیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "xóa khỏi yêu thích" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "移出收藏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "移出收藏" } } } }, "content.commands.who" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "عرض من هو متصل" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "কে অনলাইনে আছে দেখুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "sehen, wer online ist" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "see who's online" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "ver quién está en línea" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "tingnan kung sino ang online" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "voir qui est en ligne" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "ראה מי מחובר" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "कौन ऑनलाइन है देखें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "lihat siapa yang online" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "vedi chi è online" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "オンラインの人を見る" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "온라인인 사람 확인하기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "lihat siapa yang online" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "अनलाइन को-को छन् हेर्नु" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bekijk wie online is" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zobacz kto jest online" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "ver quem está online" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "ver quem está online" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "посмотреть, кто онлайн" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "se vem som är online" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "யார் ஆன்லைனில் உள்ளனர்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ดูว่าใครออนไลน์" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kimler çevrimiçi gör" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "подивитися, хто онлайн" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "دیکھیں کون آن لائن ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "xem ai đang online" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "查看谁在线" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "查看誰在線" } } } }, "content.delivery.delivered_members" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تم التسليم إلى %1$d من %2$d عضو" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%2$d জন সদস্যের মধ্যে %1$d জনের কাছে পৌঁছেছে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "zugestellt an %1$d von %2$d mitgliedern" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "delivered to %1$d of %2$d members" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "entregado a %1$d de %2$d miembros" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "naihatid sa %1$d ng %2$d miyembro" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "livré à %1$d sur %2$d membres" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "נמסר ל-%1$d מתוך %2$d חברים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%2$d सदस्यों में से %1$d को पहुँचाया" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "terkirim ke %1$d dari %2$d anggota" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "consegnato a %1$d di %2$d membri" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%2$d人中%1$d人に配信" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%2$d명 중 %1$d명에게 전달되었습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "terhantar ke %1$d dari %2$d anggota" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%2$d सदस्यमध्ये %1$d जनालाई पुर्याइयो" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bezorgd bij %1$d van %2$d leden" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "dostarczono do %1$d z %2$d członków" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "entregue a %1$d de %2$d membros" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "entregue a %1$d de %2$d membros" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "доставлено %1$d из %2$d участников" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "levererat till %1$d av %2$d medlemmar" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%2$d பேரில் %1$d பேருக்கு வழங்கப்பட்டது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ส่งถึง %1$d จาก %2$d คนแล้ว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%2$d üyeden %1$d kişiye ulaştı" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "доставлено %1$d з %2$d учасників" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%2$d میں سے %1$d اراکین کو پہنچا دیا گیا" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đã gửi tới %1$d trong %2$d thành viên" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已送达 %2$d 人中的 %1$d 人" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已送達 %2$d 人中的 %1$d 人" } } } }, "content.delivery.delivered_to" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "سُلّم إلى %@" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%@-এর কাছে পৌঁছেছে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "zugestellt an %@" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "delivered to %@" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "entregado a %@" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "naihatid kay %@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "livré à %@" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "נמסר ל-%@" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%@ को पहुँचाया" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "terkirim ke %@" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "consegnato a %@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@に配信" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%@에게 전달되었습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "terhantar ke %@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%@ लाई पुर्याइयो" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bezorgd bij %@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "dostarczono do %@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "entregue a %@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "entregue para %@" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "доставлено %@" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "levererat till %@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%@ க்கு வழங்கப்பட்டது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ส่งถึง %@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%@'a iletildi" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "доставлено %@" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%@ کو پہنچایا گیا" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "gửi tới %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已送达 %@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已送達 %@" } } } }, "content.delivery.failed" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "فشل: %@" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্যর্থ: %@" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "fehlgeschlagen: %@" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "failed: %@" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "falló: %@" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nabigo: %@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "échec : %@" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "נכשל: %@" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "विफल: %@" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "gagal: %@" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "non riuscito: %@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "失敗: %@" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "실패: %@" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "gagal: %@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "असफल: %@" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "mislukt: %@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "niepowodzenie: %@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "falhou: %@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "falhou: %@" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "ошибка: %@" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "misslyckades: %@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "தோல்வி: %@" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ล้มเหลว: %@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "başarısız: %@" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "не вдалося: %@" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ناکام: %@" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "thất bại: %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "失败:%@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "失敗:%@" } } } }, "content.delivery.read_by" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "قُرِئ بواسطة %@" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%@ পড়েছে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "gelesen von %@" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "read by %@" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "leído por %@" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "binasa ni %@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "lu par %@" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "נקרא על ידי %@" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%@ ने पढ़ा" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "dibaca oleh %@" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "letto da %@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@が既読" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%@가 읽음" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "dibaca oleh %@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%@ ले पढ्यो" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "gelezen door %@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "przeczytane przez %@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "lido por %@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "lido por %@" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "прочитано %@" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "läst av %@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%@ படித்தார்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "อ่านโดย %@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%@ okudu" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "прочитано %@" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%@ نے پڑھا" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đã đọc bởi %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已读:%@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已讀:%@" } } } }, "content.delivery.reason.blocked" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "المستخدم محظور" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্যবহারকারী ব্লক করা" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "nutzer blockiert" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "user is blocked" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "el usuario está bloqueado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "na-block ang user" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "utilisateur bloqué" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "המשתמש חסום" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "उपयोगकर्ता ब्लॉक है" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "pengguna diblokir" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "utente bloccato" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ユーザーをブロック中" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "차단된 사용자입니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "pengguna diblokir" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रयोगकर्ता ब्लक गरिएको" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "gebruiker is geblokkeerd" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "użytkownik zablokowany" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "utilizador bloqueado" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "usuário bloqueado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "пользователь заблокирован" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "användaren är blockerad" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பயனர் தடுக்கப்பட்டுள்ளார்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ผู้ใช้ถูกบล็อก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kullanıcı engellendi" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "користувач заблокований" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "صارف بلاک ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "người dùng bị chặn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "用户已被屏蔽" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "使用者已被屏蔽" } } } }, "content.delivery.reason.self" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لا يمكن الإرسال لنفسك" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "নিজেকে বার্তা পাঠানো যায় না" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "kann nicht an dich selbst senden" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "cannot message yourself" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no puedes enviarte mensajes a ti mismo" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi puwedeng magpadala sa sarili mo" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "impossible d'envoyer à toi-même" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אי אפשר לשלוח לעצמך" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "खुद को संदेश नहीं भेज सकते" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa kirim ke diri sendiri" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "impossibile inviarti il messaggio" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "自分には送れません" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "자신에게 메시지를 보낼 수 없습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa hantar ke diri sendiri" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "आफूलाई पठाउन मिल्दैन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "je kunt jezelf geen bericht sturen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nie możesz wysłać wiadomości do siebie" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "não é possível enviar mensagem a ti próprio" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "não é possível enviar mensagem para si mesmo" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "нельзя отправить себе" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kan inte skicka till dig själv" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "உங்களுக்கு நீங்களே அனுப்ப முடியாது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่สามารถส่งข้อความถึงตัวเองได้" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kendine mesaj gönderemezsin" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "не можна надіслати собі" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "خود کو پیغام نہیں بھیج سکتے" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không thể tự gửi cho chính mình" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "不能给自己发消息" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "不能給自己發訊息" } } } }, "content.delivery.reason.send_error" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "خطأ في الإرسال" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "পাঠাতে ত্রুটি" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "sende-fehler" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "send error" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "error al enviar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "error sa pagpapadala" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "erreur d'envoi" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "שגיאת שליחה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "भेजने में त्रुटि" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "kesalahan pengiriman" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "errore di invio" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "送信エラー" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "전송 오류" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "kesalahan penghantaran" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "पठाउने त्रुटि" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "verzendfout" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "błąd wysyłania" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "erro ao enviar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "erro ao enviar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "ошибка отправки" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "sändningsfel" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அனுப்பும் பிழை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เกิดข้อผิดพลาดในการส่ง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "gönderme hatası" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "помилка надсилання" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بھیجنے میں غلطی" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "lỗi gửi" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "发送错误" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "發送錯誤" } } } }, "content.delivery.reason.unknown_recipient" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مستلم غير معروف" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "অজানা প্রাপক" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "unbekannter empfänger" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "unknown recipient" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "destinatario desconocido" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi kilalang tatanggap" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "destinataire inconnu" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "נמען לא ידוע" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "अज्ञात प्राप्तकर्ता" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "penerima tidak dikenal" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "destinatario sconosciuto" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "不明な宛先" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "알 수 없는 수신자입니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "penerima tidak dikenal" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "अज्ञात प्राप्तकर्ता" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "onbekende ontvanger" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nieznany odbiorca" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "destinatário desconhecido" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "destinatário desconhecido" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "неизвестный получатель" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "okänd mottagare" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பெறுநர் தெரியவில்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่รู้จักผู้รับ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bilinmeyen alıcı" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "невідомий одержувач" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نامعلوم وصول کنندہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "người nhận không xác định" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "未知收件人" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "未知收件人" } } } }, "content.delivery.reason.unreachable" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "القرين غير متاح" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "পিয়ার পৌঁছানো যাচ্ছে না" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "peer nicht erreichbar" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "peer not reachable" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "el destinatario no es alcanzable" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi maabot ang peer" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "pair injoignable" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "עמית לא זמין" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "पीयर पहुंच योग्य नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "peer tidak dapat dijangkau" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "peer irraggiungibile" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ピアに到達できません" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "피어에 연결할 수 없습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "peer tidak dapat dijangkau" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "पीयर पहुँचयोग्य छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "peer onbereikbaar" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "peer nieosiągalny" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "par inacessível" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "par inalcançável" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "пир недостижим" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "peer ej nåbar" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "peer அணுக முடியவில்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ติดต่อเพียร์ไม่ได้" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "eşe ulaşılamıyor" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "пір недосяжний" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "peer تک رسائی نہیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không liên lạc được với nút" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "同伴不可达" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "同伴不可達" } } } }, "content.header.people" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "أشخاص" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "মানুষ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "PERSONEN" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "PEOPLE" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "PERSONAS" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "PEOPLE" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "PERSONNES" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אנשים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "लोग" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "ORANG" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "PERSONE" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ユーザー" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "참여자" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "ORANG" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "मानिस" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "MENSEN" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "OSOBY" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "PESSOAS" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "PESSOAS" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "ЛЮДИ" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "PERSONER" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "PEOPLE" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "PEOPLE" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "KİŞİLER" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "ЛЮДИ" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "PEOPLE" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "PEOPLE" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "成员" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "成員" } } } }, "content.help.verification" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "التحقق: عرض رمز qr الخاص بي أو مسح صديق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "যাচাই: আমার QR দেখান বা বন্ধুরটি স্ক্যান করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verifizierung: meinen qr zeigen oder freund scannen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "verification: show my QR or scan a friend" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "verificación: mostrar mi QR o escanear a un amigo" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "beripikasyon: ipakita ang aking QR o i-scan ang sa kaibigan" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "vérification : afficher mon qr ou scanner un ami" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אימות: הצג את ה-qr שלי או סרוק חבר" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "सत्यापन: मेरा QR दिखाएँ या मित्र का स्कैन करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "verifikasi: tampilkan qr-ku atau pindai teman" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "verifica: mostra il mio qr o scansiona un amico" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "検証: 自分のqrを表示するか友達をスキャン" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "인증: 내 QR을 보여주거나 친구의 QR 스캔하기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "verifikasi: tampilkan qr-ku atau pindai teman" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रमाणीकरण: मेरो qr देखाउ वा साथी स्क्यान गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "verificatie: toon mijn QR of scan die van een vriend" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "weryfikacja: pokaż mój QR lub zeskanuj znajomego" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "verificação: mostra o meu QR ou lê o de um amigo" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "verificação: mostrar meu qr ou escanear um amigo" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "верификация: показать мой qr или сканировать друга" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "verifiering: visa min QR eller skanna en vän" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சரிபார்ப்பு: எனது QR ஐ காட்டவும் அல்லது நண்பரின் QR ஐ ஸ்கேன் செய்யவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "การยืนยัน: แสดง QR ของฉันหรือสแกนของเพื่อน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "doğrulama: QR kodumu göster veya bir arkadaşını tara" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "верифікація: показати мій qr або сканувати друга" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "توثیق: میرا QR دکھائیں یا دوست کا اسکین کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "xác minh: hiển thị QR của tôi hoặc quét của bạn bè" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "验证:展示我的 qr 或扫描好友" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "驗證:展示我的 qr 或掃描好友" } } } }, "content.input.message_placeholder" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "اكتب رسالة..." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "একটি বার্তা লিখুন..." } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "nachricht eingeben..." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "type a message..." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "escribe un mensaje..." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "mag-type ng mensahe..." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "écris un message..." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "כתוב הודעה..." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "संदेश लिखें..." } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "ketik pesan..." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "scrivi un messaggio..." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "メッセージを入力..." } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "메시지를 입력하세요..." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "ketik pesan..." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सन्देश टाइप गर..." } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "typ een bericht..." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wpisz wiadomość..." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "escreve uma mensagem..." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "digite uma mensagem..." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "напиши сообщение..." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "skriv ett meddelande..." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "ஒரு செய்தியைத் தட்டச்சு செய்க..." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "พิมพ์ข้อความ..." } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bir mesaj yazın..." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "напиши повідомлення..." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "پیغام ٹائپ کریں..." } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "nhập tin nhắn..." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "输入消息..." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "輸入訊息..." } } } }, "content.input.nickname_placeholder" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لقب" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ডাকনাম" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "nickname" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "nickname" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "apodo" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "palayaw" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "pseudo" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "כינוי" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "उपनाम" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "nama panggilan" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "nickname" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ニックネーム" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "닉네임" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "nama panggilan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "उपनाम" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bijnaam" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "pseudonim" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "apelido" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "apelido" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "ник" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "smeknamn" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "புனைப் பெயர்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ชื่อเล่น" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "takma ad" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "нік" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نک نیم" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "biệt danh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "昵称" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "暱稱" } } } }, "content.location.enable" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تفعيل الموقع" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "লোকেশন চালু করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "standort aktivieren" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "enable location" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "activar ubicación" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-on ang lokasyon" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "activer la localisation" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הפעל מיקום" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "लोकेशन सक्षम करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "aktifkan lokasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "attiva posizione" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "位置情報を有効化" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "위치 활성화" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "aktifkan lokasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "स्थान सक्षम गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "locatie inschakelen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "włącz lokalizację" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "ativar localização" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "habilitar localização" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "включить локацию" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "aktivera plats" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இடத்தை இயக்கு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เปิดใช้งานตำแหน่ง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "konumu etkinleştir" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "увімкнути локацію" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "لوکیشن فعال کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "bật vị trí" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "启用位置" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "啟用位置" } } } }, "content.message.copy" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "نسخ الرسالة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বার্তা কপি করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "nachricht kopieren" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "copy message" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "copiar mensaje" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "kopyahin ang mensahe" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "copier le message" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "העתק הודעה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "संदेश कॉपी करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "salin pesan" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "copia messaggio" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "メッセージをコピー" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "메시지 복사" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "salin pesan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सन्देश प्रतिलिपि गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bericht kopiëren" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "kopiuj wiadomość" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "copiar mensagem" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "copiar mensagem" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "копировать сообщение" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kopiera meddelande" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "செய்தியை நகலெடு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "คัดลอกข้อความ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "mesajı kopyala" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "скопіювати повідомлення" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "پیغام کاپی کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "sao chép tin nhắn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "复制消息" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "複製訊息" } } } }, "content.message.show_less" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "عرض أقل" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "কম দেখান" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "weniger anzeigen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "show less" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "mostrar menos" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "ipakita nang kaunti" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "afficher moins" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הצג פחות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "कम दिखाएँ" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tampilkan lebih sedikit" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "mostra meno" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "表示を減らす" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "간략히 보기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tampilkan lebih sedikit" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "थोरै देखाउ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "minder weergeven" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "pokaż mniej" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "mostrar menos" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "mostrar menos" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "показать меньше" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "visa mindre" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறைவாக காட்ட" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "แสดงน้อยลง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "daha az göster" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "показати менше" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "کم دکھائیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "thu gọn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "收起" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "收起" } } } }, "content.message.show_more" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "عرض المزيد" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "আরও দেখান" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "mehr anzeigen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "show more" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "mostrar más" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "ipakita pa" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "afficher plus" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הצג עוד" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "और दिखाएँ" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tampilkan lebih banyak" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "mostra di più" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "さらに表示" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "더 보기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tampilkan lebih banyak" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "थप देखाउ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "meer weergeven" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "pokaż więcej" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "mostrar mais" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "mostrar mais" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "показать больше" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "visa mer" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "மேலும் காட்ட" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "แสดงเพิ่มเติม" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "daha fazla göster" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "показати більше" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "مزید دکھائیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "mở rộng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "展开" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "展開" } } } }, "content.notes.location_unavailable" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "الموقع غير متاح" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "অবস্থান অনুপলব্ধ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "standort nicht verfügbar" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "location unavailable" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "ubicación no disponible" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "walang lokasyon" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "localisation indisponible" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "המיקום לא זמין" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "लोकेशन उपलब्ध नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "lokasi tidak tersedia" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "posizione non disponibile" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "位置情報を取得できません" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "위치 사용 불가" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "lokasi tidak tersedia" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "स्थान उपलब्ध छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "locatie niet beschikbaar" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "lokalizacja niedostępna" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "localização indisponível" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "localização indisponível" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "локация недоступна" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "plats ej tillgänglig" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இடம் கிடைக்கவில்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่มีข้อมูลตำแหน่ง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "konum kullanılamıyor" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "локація недоступна" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "لوکیشن دستیاب نہیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không có dữ liệu vị trí" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "位置不可用" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "位置不可用" } } } }, "content.notes.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "ملاحظات" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "নোট" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "notizen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "notes" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "notas" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "mga tala" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "notes" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הערות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "नोट्स" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "catatan" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "note" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ノート" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "노트" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "catatan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "नोट" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "notities" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "notatki" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "notas" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "notas" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "заметки" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "anteckningar" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறிப்புகள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "บันทึก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "notlar" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "замітки" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نوٹس" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "ghi chú" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "笔记" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "筆記" } } } }, "content.payment.cashu" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "الدفع عبر cashu" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ক্যাশু দিয়ে পরিশোধ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "per cashu bezahlen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "pay via cashu" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "pagar con Cashu" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "magbayad gamit ang Cashu" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "payer via cashu" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "תשלום דרך cashu" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "कैशु से भुगतान करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "bayar via cashu" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "paga con cashu" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "cashuで支払う" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "cashu로 결제" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "bayar via cashu" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "cashu मार्फत तिर्नु" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "betalen met Cashu" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zapłać przez Cashu" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "pagar com Cashu" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "pagar via cashu" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "оплатить через cashu" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "betala med Cashu" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "Cashu மூலம் செலுத்தவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "จ่ายด้วย Cashu" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "cashu ile öde" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "оплатити через cashu" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "Cashu سے ادائیگی کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "thanh toán bằng Cashu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "通过 cashu 支付" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "透過 cashu 支付" } } } }, "content.payment.lightning" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "الدفع عبر lightning" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "লাইটনিং দিয়ে পরিশোধ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "per lightning bezahlen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "pay via lightning" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "pagar con Lightning" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "magbayad gamit ang Lightning" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "payer via lightning" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "תשלום דרך lightning" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "लाइटनिंग से भुगतान करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "bayar via lightning" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "paga con lightning" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "lightningで支払う" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "lightning으로 결제" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "bayar via lightning" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "lightning मार्फत तिर्नु" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "betalen met Lightning" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zapłać przez Lightning" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "pagar com Lightning" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "pagar via lightning" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "оплатить через lightning" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "betala med Lightning" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "Lightning மூலம் செலுத்தவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "จ่ายด้วย Lightning" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "lightning ile öde" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "оплатити через lightning" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "Lightning سے ادائیگی کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "thanh toán bằng Lightning" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "通过 lightning 支付" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "透過 lightning 支付" } } } }, "encryption.accessibility.establishing" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "جار إعداد التشفير" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এনক্রিপশন স্থাপিত হচ্ছে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verschlüsselung wird aufgebaut" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "establishing encryption" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "estableciendo cifrado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nagseset up ng pag-encrypt" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "établissement du chiffrement" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הצפנה בהקמה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एन्क्रिप्शन स्थापित हो रहा है" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "menyiapkan enkripsi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "avvio crittografia" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "暗号を確立しています" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "암호화 중" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "menyiapkan enkripsi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सङ्केत सेट हुँदै" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "versleuteling wordt opgezet" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "trwa ustanawianie szyfrowania" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "a estabelecer encriptação" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "estabelecendo criptografia" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "устанавливается шифрование" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ställer in kryptering" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறியாக்கம் அமைக்கப்படுகிறது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "กำลังตั้งค่าการเข้ารหัส" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şifreleme kuruluyor" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "встановлюється шифрування" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "انکرپشن قائم کی جا رہی ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đang thiết lập mã hóa" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在建立加密" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在建立加密" } } } }, "encryption.accessibility.failed" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "فشل التشفير" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এনক্রিপশন ব্যর্থ হয়েছে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verschlüsselung fehlgeschlagen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "encryption failed" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cifrado fallido" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nabigo ang pag-encrypt" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "chiffrement échoué" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הצפנה נכשלה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एन्क्रिप्शन विफल हुआ" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "enkripsi gagal" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "crittografia fallita" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "暗号に失敗" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "암호화 실패" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "enkripsi gagal" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सङ्केत असफल" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "versleuteling mislukt" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "szyfrowanie nie powiodło się" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "encriptação falhada" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "falha na criptografia" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "шифрование не удалось" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kryptering misslyckades" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறியாக்கம் தோல்வியடைந்தது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "การเข้ารหัสล้มเหลว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şifreleme başarısız" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "шифрування не вдалося" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "انکرپشن ناکام رہی" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "mã hóa thất bại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "加密失败" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "加密失敗" } } } }, "encryption.accessibility.not_encrypted" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "غير مشفر" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এনক্রিপ্ট করা নেই" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "nicht verschlüsselt" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "not encrypted" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "sin cifrar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "walang pag-encrypt" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "non chiffré" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "לא מוצפן" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एन्क्रिप्ट नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak terenkripsi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "non crittografato" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "未暗号" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "암호화되지 않음" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak terenkripsi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सङ्केत छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "niet versleuteld" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "brak szyfrowania" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "sem encriptação" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "não criptografado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "не зашифровано" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "inte krypterad" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறியாக்கம் செய்யப்படவில்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่ได้เข้ารหัส" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şifrelenmedi" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "не зашифровано" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "غیر انکرپٹڈ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chưa mã hóa" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "未加密" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "未加密" } } } }, "encryption.accessibility.secured" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مشفر" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এনক্রিপ্ট করা" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verschlüsselt" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "encrypted" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cifrado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "na-encrypt" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "chiffré" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מוצפן" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एन्क्रिप्टेड" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "terenkripsi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "crittografato" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "暗号化済み" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "암호화됨" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "terenkripsi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सङ्केत गरिएको" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "versleuteld" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zaszyfrowane" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "encriptado" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "criptografado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "зашифровано" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "krypterad" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறியாக்கம் செய்யப்பட்டது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เข้ารหัสแล้ว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şifreli" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "зашифровано" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "انکرپٹڈ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đã mã hóa" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已加密" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已加密" } } } }, "encryption.accessibility.verified" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مشفر ومُتحقق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এনক্রিপ্ট ও যাচাইকৃত" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verschlüsselt und verifiziert" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "encrypted and verified" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cifrado y verificado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "na-encrypt at beripikado" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "chiffré et vérifié" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מוצפן ומאומת" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एन्क्रिप्टेड और सत्यापित" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "terenkripsi dan terverifikasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "crittografato e verificato" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "暗号化し検証済み" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "암호화 및 인증됨" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "terenkripsi dan terverifikasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सङ्केत र प्रमाणित" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "versleuteld en geverifieerd" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zaszyfrowane i zweryfikowane" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "encriptado e verificado" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "criptografado e verificado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "зашифровано и проверено" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "krypterad och verifierad" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறியாக்கமும் சரிபார்ப்பும் முடிந்தது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เข้ารหัสและยืนยันแล้ว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şifreli ve doğrulandı" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "зашифровано та перевірено" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "انکرپٹڈ اور تصدیق شدہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đã mã hóa và xác thực" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已加密并验证" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已加密並驗證" } } } }, "encryption.status.establishing" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "جار إعداد التشفير..." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এনক্রিপশন স্থাপিত হচ্ছে..." } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verschlüsselung wird aufgebaut..." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "establishing encryption..." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "estableciendo cifrado..." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nagseset up ng pag-encrypt..." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "mise en place du chiffrement..." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מקימים הצפנה..." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एन्क्रिप्शन स्थापित हो रहा है..." } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "menyiapkan enkripsi..." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "avvio della crittografia..." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "暗号を確立中..." } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "암호화 중..." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "menyiapkan enkripsi..." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सङ्केत सेट गर्दै..." } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "versleuteling wordt opgezet..." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "trwa ustanawianie szyfrowania..." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "a estabelecer encriptação..." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "estabelecendo criptografia..." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "устанавливаем шифрование..." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ställer in kryptering..." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறியாக்கம் அமைக்கப்படுகிறது..." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "กำลังตั้งค่าการเข้ารหัส..." } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şifreleme kuruluyor..." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "встановлюємо шифрування..." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "انکرپشن قائم کی جا رہی ہے..." } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đang thiết lập mã hóa..." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在建立加密..." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在建立加密..." } } } }, "encryption.status.failed" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "فشل التشفير" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এনক্রিপশন ব্যর্থ হয়েছে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verschlüsselung fehlgeschlagen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "encryption failed" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cifrado fallido" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nabigo ang pag-encrypt" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "chiffrement échoué" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הצפנה נכשלה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एन्क्रिप्शन विफल हुआ" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "enkripsi gagal" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "crittografia fallita" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "暗号に失敗" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "암호화 실패" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "enkripsi gagal" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सङ्केत असफल" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "versleuteling mislukt" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "szyfrowanie nie powiodło się" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "encriptação falhada" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "falha na criptografia" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "шифрование не удалось" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kryptering misslyckades" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறியாக்கம் தோல்வியடைந்தது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "การเข้ารหัสล้มเหลว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şifreleme başarısız" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "шифрування не вдалося" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "انکرپشن ناکام رہی" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "mã hóa thất bại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "加密失败" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "加密失敗" } } } }, "encryption.status.not_encrypted" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "غير مشفر" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এনক্রিপ্ট করা নেই" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "nicht verschlüsselt" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "not encrypted" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "sin cifrar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "walang pag-encrypt" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "non chiffré" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "לא מוצפן" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एन्क्रिप्ट नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak terenkripsi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "non crittografato" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "未暗号" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "암호화되지 않음" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak terenkripsi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सङ्केत छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "niet versleuteld" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "brak szyfrowania" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "sem encriptação" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "não criptografado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "не зашифровано" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "inte krypterad" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறியாக்கம் இல்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่ได้เข้ารหัส" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şifrelenmedi" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "не зашифровано" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "غیر انکرپٹڈ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chưa mã hóa" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "未加密" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "未加密" } } } }, "encryption.status.secured" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مشفر" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এনক্রিপ্ট করা" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verschlüsselt" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "encrypted" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cifrado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "na-encrypt" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "chiffré" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מוצפן" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एन्क्रिप्टेड" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "terenkripsi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "crittografato" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "暗号化済み" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "암호화됨" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "terenkripsi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सङ्केत गरिएको" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "versleuteld" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zaszyfrowane" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "encriptado" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "criptografado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "зашифровано" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "krypterad" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறியாக்கப்பட்டது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เข้ารหัสแล้ว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şifreli" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "зашифровано" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "انکرپٹڈ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đã mã hóa" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已加密" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已加密" } } } }, "encryption.status.verified" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مشفر ومُتحقق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এনক্রিপ্ট করা ও যাচাইকৃত" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verschlüsselt und verifiziert" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "encrypted & verified" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cifrado y verificado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "na-encrypt at beripikado" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "chiffré et vérifié" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מוצפן ומאומת" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एन्क्रिप्टेड और सत्यापित" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "terenkripsi dan terverifikasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "crittografato e verificato" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "暗号化し検証済み" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "암호화 및 인증됨" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "terenkripsi dan terverifikasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सङ्केत र प्रमाणित" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "versleuteld & geverifieerd" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zaszyfrowane i zweryfikowane" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "encriptado e verificado" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "criptografado e verificado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "зашифровано и проверено" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "krypterad & verifierad" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறியாக்கப்பட்ட & சரிபார்க்கப்பட்டது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เข้ารหัสและยืนยันแล้ว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şifreli ve doğrulandı" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "зашифровано та перевірено" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "انکرپٹڈ اور تصدیق شدہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đã mã hóa & xác thực" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已加密并验证" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已加密並驗證" } } } }, "fingerprint.action.mark_verified" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "وضع علامة تم التحقق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "যাচাইকৃত হিসেবে চিহ্নিত করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "als verifiziert markieren" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "mark as verified" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "marcar como verificado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "markahan bilang beripikado" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "marquer comme vérifié" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "סמן כמאומת" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "सत्यापित के रूप में चिह्नित करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tandai sebagai terverifikasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "segna come verificato" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "検証済みにする" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "인증됨으로 표시" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tandai sebagai terverifikasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रमाणित चिन्ह लगाउ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "markeer als geverifieerd" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "oznacz jako zweryfikowane" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "marcar como verificado" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "marcar como verificado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "пометить как проверено" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "markera som verifierad" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சரிபார்க்கப்பட்டது என குறிக்க" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ทำเครื่องหมายว่ายืนยันแล้ว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "doğrulandı olarak işaretle" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "позначити як перевірено" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "تصدیق شدہ کے طور پر نشان زد کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đánh dấu đã xác thực" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "标记为已验证" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "標記為已驗證" } } } }, "fingerprint.action.remove_verification" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إزالة التحقق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "যাচাইকরণ সরান" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verifizierung entfernen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "remove verification" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "eliminar verificación" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "alisin ang beripikasyon" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "retirer la vérification" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הסר אימות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "सत्यापन हटाएँ" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "hapus verifikasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "rimuovi verifica" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "検証を削除" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "인증 제거" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "hapus verifikasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रमाणीकरण हटाउ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "verificatie verwijderen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "usuń weryfikację" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "remover verificação" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "remover verificação" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "удалить проверку" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ta bort verifiering" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சரிபார்ப்பை நீக்கு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ลบการยืนยัน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "doğrulamayı kaldır" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "зняти перевірку" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "تصدیق ہٹائیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "xóa xác thực" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "移除验证" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "移除驗證" } } } }, "fingerprint.badge.not_verified" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ غير مُتحقق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ যাচাইকৃত নয়" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ NICHT VERIFIZIERT" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ NOT VERIFIED" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ NO VERIFICADO" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ HINDI BERIPIKADO" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ NON VÉRIFIÉ" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ לא מאומת" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ सत्यापित नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ BELUM TERVERIFIKASI" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ NON VERIFICATO" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ 未検証" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ 인증되지 않음" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ BELUM TERVERIFIKASI" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ प्रमाणित छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ NIET GEVERIFICEERD" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ NIEZWERYFIKOWANE" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ NÃO VERIFICADO" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ NÃO VERIFICADO" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ НЕ ПРОВЕРЕНО" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ INTE VERIFIERAD" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ சரிபார்க்கப்படவில்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ ยังไม่ยืนยัน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ DOĞRULANMADI" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ НЕ ПЕРЕВІРЕНО" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ تصدیق نہیں ہوئی" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ CHƯA XÁC THỰC" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ 未验证" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "⚠️ 未驗證" } } } }, "fingerprint.badge.verified" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "✓ مُتحقق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "✓ যাচাইকৃত" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "✓ VERIFIZIERT" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "✓ VERIFIED" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "✓ VERIFICADO" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "✓ BERIPIKADO" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "✓ VÉRIFIÉ" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "✓ מאומת" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "✓ सत्यापित" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "✓ TERVERIFIKASI" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "✓ VERIFICATO" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "✓ 検証済み" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "✓ 인증됨" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "✓ TERVERIFIKASI" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "✓ प्रमाणित" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "✓ GEVERIFICEERD" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "✓ ZWERYFIKOWANE" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "✓ VERIFICADO" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "✓ VERIFICADO" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "✓ ПРОВЕРЕНО" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "✓ VERIFIERAD" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "✓ சரிபார்க்கப்பட்டது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "✓ ยืนยันแล้ว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "✓ DOĞRULANDI" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "✓ ПЕРЕВІРЕНО" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "✓ تصدیق شدہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "✓ ĐÃ XÁC THỰC" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "✓ 已验证" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "✓ 已驗證" } } } }, "fingerprint.handshake_pending" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "غير متاح - جار تنفيذ handshake" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "উপলব্ধ নয় - হ্যান্ডশেক চলছে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "nicht verfügbar – handshake läuft" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "not available - handshake in progress" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no disponible: el handshake está en curso" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi pa magagamit - nagpapatuloy ang handshake" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "indisponible - handshake en cours" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "לא זמין - handshake מתבצע" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "उपलब्ध नहीं - हैंडशेक जारी है" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak tersedia - handshake sedang berlangsung" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "non disponibile - handshake in corso" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "利用不可 - handshake進行中" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "사용 불가 - 핸드셰이크 진행 중" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak tersedia - handshake sedang berlangsung" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "उपलब्ध छैन - handshake हुँदै" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "niet beschikbaar - handshake bezig" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "niedostępne – handshake w toku" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "indisponível - aperto de mão em curso" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "indisponível - handshake em andamento" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "недоступно — handshake выполняется" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "inte tillgänglig – handskakning pågår" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "கிடைக்கவில்லை - கைச்சாத்து நடந்து கொண்டிருக்கிறது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ยังไม่พร้อม - กำลังจับมือ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kullanılamıyor - el sıkışma sürüyor" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "недоступно — handshake триває" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "دستیاب نہیں - ہینڈ شیک جاری ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chưa sẵn sàng - đang bắt tay" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "暂不可用 - handshake 进行中" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "暫不可用 - handshake 進行中" } } } }, "fingerprint.message.verified" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لقد تحققت من هوية هذا الشخص." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "আপনি এই ব্যক্তির পরিচয় যাচাই করেছেন।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "du hast die identität dieser person verifiziert." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "you have verified this person's identity." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "has verificado la identidad de esta persona." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "na-beripika mo na ang pagkakakilanlan ng taong ito." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "tu as vérifié l'identité de cette personne." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אישרת את זהותו של האדם הזה." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "आपने इस व्यक्ति की पहचान सत्यापित की है।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "kamu sudah memverifikasi identitas orang ini." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "hai verificato l'identità di questa persona." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "この人の身元を確認しました。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 사람의 신원을 인증했습니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "kamu sudah memverifikasi identitas orang ini." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "तिमीले यस व्यक्तिको पहिचान प्रमाणित गरेको छौ." } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "je hebt de identiteit van deze persoon geverifieerd." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zweryfikowałeś tożsamość tej osoby." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "confirmaste a identidade desta pessoa." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "você verificou a identidade dessa pessoa." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "ты подтвердил личность этого человека." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "du har verifierat den här personens identitet." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இந்த நபரின் அடையாளத்தை நீங்கள் உறுதிப்படுத்தியுள்ளீர்கள்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "คุณยืนยันตัวตนของคนนี้แล้ว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bu kişinin kimliğini doğruladınız." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "ти підтвердив особу цієї людини." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "آپ نے اس شخص کی شناخت کی تصدیق کی ہے۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "bạn đã xác thực danh tính người này." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "你已经核实了此人的身份。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "你已經覈實了此人的身份。" } } } }, "fingerprint.message.verify_hint" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "قارن هذه البصمات مع %@ عبر قناة آمنة." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "নিরাপদ চ্যানেলে %@-এর সঙ্গে এই ফিঙ্গারপ্রিন্ট মিলিয়ে দেখুন।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "vergleiche diese fingerabdrücke mit %@ über einen sicheren kanal." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "compare these fingerprints with %@ using a secure channel." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "compara estas huellas con %@ mediante un canal seguro." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "ikumpara ang mga fingerprint na ito kay %@ sa isang ligtas na channel." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "compare ces empreintes avec %@ via un canal sécurisé." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "השווה את הטביעות עם %@ בערוץ מאובטח." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "इन फिंगरप्रिंट्स की तुलना %@ से सुरक्षित चैनल पर करें।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "bandingkan sidik ini dengan %@ lewat kanal aman." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "confronta queste impronte con %@ tramite un canale sicuro." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "これらのフィンガープリントを%@と安全なチャネルで比較" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "보안 채널을 통해 이 지문을 %@와 비교하세요." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "bandingkan sidik ini dengan %@ lewat kanal aman." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "यी फिङ्गरप्रिन्टहरू %@ सँग सुरक्षित च्यानलमा तुलना गर।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "vergelijk deze fingerprints met %@ via een veilig kanaal." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "porównaj te odciski z %@ na bezpiecznym kanale." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "compara estas impressões com %@ num canal seguro." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "compare essas impressões com %@ usando um canal seguro." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "сравни эти отпечатки с %@ по безопасному каналу." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "jämför dessa fingeravtryck med %@ via en säker kanal." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இந்த கைரேகைகளை %@ உடன் பாதுகாப்பான வழியில் ஒப்பிடவும்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เปรียบเทียบลายนิ้วมือเหล่านี้กับ %@ ผ่านช่องทางที่ปลอดภัย" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bu parmak izlerini %@ ile güvenli bir kanalda karşılaştırın." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "порівняй ці відбитки з %@ у безпечному каналі." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ان فنگر پرنٹس کو %@ کے ساتھ محفوظ چینل پر ملائیں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "so sánh các vân tay này với %@ qua kênh an toàn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "通过安全渠道与 %@ 比对这些指纹。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "透過安全渠道與 %@ 比對這些指紋。" } } } }, "fingerprint.their_label" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "بصمتهم:" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "তাদের ফিঙ্গারপ্রিন্ট:" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "deren fingerabdruck:" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "their fingerprint:" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "huella de la otra persona:" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "fingerprint nila:" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "leur empreinte :" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הטבעת שלהם:" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "उनका फिंगरप्रिंट:" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "sidik mereka:" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "impronta loro:" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "相手のフィンガープリント:" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "상대방의 지문:" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "sidik mereka:" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "उनको फिङ्गरप्रिन्ट:" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "hun fingerprint:" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "ich odcisk:" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "impressão digital desta pessoa:" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "impressão digital deles:" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "их отпечаток:" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "deras fingeravtryck:" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அவர்களின் கைரேகம்:" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ลายนิ้วมือของอีกฝ่าย:" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "onların parmak izi:" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "їхній відбиток:" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ان کا فنگر پرنٹ:" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "vân tay của họ:" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "对方指纹:" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "對方指紋:" } } } }, "fingerprint.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تحقق الأمان" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "নিরাপত্তা যাচাই" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "sicherheitsverifizierung" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "security verification" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "verificación de seguridad" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "beripikasyong panseguridad" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "vérification de sécurité" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אימות אבטחה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "सुरक्षा सत्यापन" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "verifikasi keamanan" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "verifica di sicurezza" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "セキュリティ検証" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "보안 인증" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "verifikasi keamanan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सुरक्षा प्रमाणीकरण" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "beveiligingsverificatie" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "weryfikacja bezpieczeństwa" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "verificação de segurança" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "verificação de segurança" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "проверка безопасности" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "säkerhetsverifiering" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பாதுகாப்பு சரிபார்ப்பு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "การยืนยันความปลอดภัย" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "güvenlik doğrulaması" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "перевірка безпеки" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "سیکیورٹی تصدیق" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "xác minh bảo mật" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "安全验证" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "安全驗證" } } } }, "fingerprint.your_label" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "بصمتك:" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "আপনার ফিঙ্গারপ্রিন্ট:" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "dein fingerabdruck:" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "your fingerprint:" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "tu huella:" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "fingerprint mo:" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "ton empreinte :" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הטבעת שלך:" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "आपका फिंगरप्रिंट:" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "sidikmu:" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "tua impronta:" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "あなたのフィンガープリント:" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "나의 지문:" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "sidikmu:" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "तिम्रो फिङ्गरप्रिन्ट:" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "jouw fingerprint:" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "twój odcisk:" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "a tua impressão digital:" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "sua impressão digital:" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "твой отпечаток:" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ditt fingeravtryck:" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "உங்கள் கைரேகம்:" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ลายนิ้วมือของคุณ:" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "sizin parmak iziniz:" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "твій відбиток:" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "آپ کا فنگر پرنٹ:" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "vân tay của bạn:" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "你的指纹:" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "你的指紋:" } } } }, "geohash_people.action.block" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "حظر" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্লক করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "blockieren" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "block" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "bloquear" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-block" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "bloquer" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "חסום" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "ब्लॉक करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "blokir" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "blocca" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ブロック" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "차단하기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "blokir" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "ब्लक" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "blokkeren" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zablokuj" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "bloquear" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "bloquear" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "заблокировать" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "blockera" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "தடை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "บล็อก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "engelle" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "заблокувати" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بلاک کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chặn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "屏蔽" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "屏蔽" } } } }, "geohash_people.action.unblock" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إلغاء الحظر" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "আনব্লক করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "entsperren" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "unblock" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "desbloquear" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-unblock" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "débloquer" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "בטל חסימה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "अनब्लॉक करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "buka blokir" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "sblocca" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ブロック解除" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "차단 해제하기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "buka blokir" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "अनब्लक" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "deblokkeren" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "odblokuj" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "desbloquear" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "desbloquear" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "разблокировать" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "avblockera" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "தடை நீக்கு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เลิกบล็อก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "engeli kaldır" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "розблокувати" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بلاک ہٹائیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "bỏ chặn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "取消屏蔽" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "取消屏蔽" } } } }, "geohash_people.none_nearby" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لا أحد قريب..." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "কাছে কেউ নেই..." } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "niemand in der nähe..." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "nobody around..." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "nadie cerca..." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "walang tao sa paligid..." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "personne à proximité..." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אין אף אחד בסביבה..." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "आसपास कोई नहीं..." } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak ada siapa pun..." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "nessuno nei dintorni..." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "近くに誰もいません..." } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "주변에 아무도 없습니다..." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak ada siapa pun..." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "वरिपरि कोही छैन..." } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "niemand in de buurt..." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nikogo w pobliżu..." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "ninguém por perto..." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "ninguém por perto..." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "никого рядом..." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ingen i närheten..." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அருகில் யாரும் இல்லை..." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่มีใครอยู่ใกล้..." } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "yakında kimse yok..." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "поруч нікого..." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "قریب کوئی نہیں..." } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không có ai gần..." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "附近没人..." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "附近沒人..." } } } }, "geohash_people.tooltip.blocked" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "محظور في geohash" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "জিওহ্যাশে ব্লক করা" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "in geohash blockiert" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "blocked in geohash" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "bloqueado en geohash" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "na-block sa geohash" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "bloqué dans geohash" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "חסום ב-geohash" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "जियोहैश में ब्लॉक" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "diblokir di geohash" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "bloccato su geohash" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "geohashでブロック中" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "geohash에서 차단됨" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "diblokir di geohash" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "geohash मा ब्लक" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "geblokkeerd in geohash" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zablokowany w geohash" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "bloqueado em geohash" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "bloqueado em geohash" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "заблокирован в geohash" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "blockerad i geohash" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "geohash இல் தடுக்கப்பட்டது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ถูกบล็อกใน geohash" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "geohash'te engellendi" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "заблоковано в geohash" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "geohash میں بلاک شدہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đã chặn trong geohash" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "在 geohash 中已屏蔽" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "在 geohash 中已屏蔽" } } } }, "geohash_people.you_suffix" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : " (أنت)" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : " (আপনি)" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : " (du)" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : " (you)" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : " (tú)" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : " (ikaw)" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : " (toi)" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : " (אתה)" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : " (आप)" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : " (kamu)" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : " (tu)" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : " (あなた)" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : " (나)" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : " (kamu)" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : " (तिमी)" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : " (jij)" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : " (ty)" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : " (tu)" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : " (você)" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : " (ты)" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : " (du)" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : " (நீங்கள்)" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : " (คุณ)" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : " (sen)" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : " (ти)" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : " (آپ)" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : " (bạn)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : " (你)" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : " (你)" } } } }, "location_channels.action.open_settings" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "فتح الإعدادات" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "সেটিংস খুলুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "einstellungen öffnen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "open settings" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "abrir ajustes" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "buksan ang settings" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "ouvrir réglages" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "פתח הגדרות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "सेटिंग्स खोलें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "buka pengaturan" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "apri impostazioni" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "設定を開く" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "설정 열기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "buka Tetapan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सेटिङ खोल" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "open instellingen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "otwórz ustawienia" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "abrir definições" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "abrir ajustes" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "открыть настройки" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "öppna inställningar" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அமைப்புகளைத் திறக்க" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เปิดการตั้งค่า" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "ayarları aç" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "відкрити налаштування" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "سیٹنگز کھولیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "mở cài đặt" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "打开设置" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "打開設定" } } } }, "location_channels.action.remove_access" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إزالة صلاحية الموقع" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "লোকেশন অ্যাক্সেস সরান" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "standortzugriff entfernen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "remove location access" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "eliminar acceso a la ubicación" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "alisin ang access sa lokasyon" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "retirer l'accès localisation" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הסר גישת מיקום" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "लोकेशन एक्सेस हटाएँ" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "cabut akses lokasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "revoca accesso alla posizione" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "位置アクセスを解除" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "위치 접근 권한 제거" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "cabut capaian lokasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "स्थान पहुँच हटाउ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "toegang tot locatie verwijderen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "usuń dostęp do lokalizacji" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "remover acesso à localização" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "remover acesso à localização" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "отключить доступ к локации" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ta bort platsåtkomst" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இட அணுகலை அகற்று" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "นำสิทธิ์เข้าถึงตำแหน่งออก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "konum erişimini kaldır" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "відключити доступ до локації" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "لوکیشن رسائی ہٹائیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "gỡ quyền truy cập vị trí" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "移除位置访问" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "移除位置存取權" } } } }, "location_channels.action.request_permissions" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "جلب موقعي و geohash" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "লোকেশন অনুমতি ও আমার জিওহ্যাশ নিন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "standort und geohash abrufen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "get location and my geohashes" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "obtener mi ubicación y mis geohashes" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "kumuha ng access sa lokasyon at mga geohash ko" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "obtenir ma localisation et mes geohash" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "קבל את המיקום וה-geohash שלי" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "लोकेशन अनुमति और मेरे जियोहैश प्राप्त करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "ambil lokasiku dan geohash" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "ottieni la mia posizione e i geohash" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "位置情報とgeohashを取得" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "내 위치 및 geohashes 가져오기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "ambil lokasiku dan geohash" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "मेरो स्थान र geohash प्राप्त गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "vraag locatierechten en mijn geohashes op" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "uzyskaj uprawnienia lokalizacji i moje geohashe" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "obter permissões de localização e os meus geohash" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "obter localização e meus geohashes" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "получить мою локацию и geohash" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "hämta platsbehörighet och mina geohashar" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இட அனுமதி மற்றும் என் geohash களைப் பெற" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ขอสิทธิ์ตำแหน่งและ geohash ของฉัน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "konum izni ve geohash'lerimi al" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "отримати мою локацію та geohash" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "لوکیشن کی اجازت اور میرے geohash حاصل کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "yêu cầu quyền vị trí và geohash của tôi" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "获取位置和我的 geohash" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "獲取位置和我的 geohash" } } } }, "location_channels.action.teleport" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "انتقال فوري" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "টেলিপোর্ট" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "teleportieren" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "teleport" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "teletransportar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "teleport" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "téléporter" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "טלפורט" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "टेलीपोर्ट" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "teleport" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "teletrasporto" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "テレポート" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "텔레포트" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "teleport" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "टेलिपोर्ट" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "teleporteer" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "teleportuj" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "teletransportar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "teletransportar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "телепорт" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "teleportera" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "டெலிபோர்ட்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เทเลพอร์ต" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "ışınlan" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "телепорт" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ٹیلی پورٹ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "dịch chuyển" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "瞬移" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "瞬移" } } } }, "location_channels.bookmarked_section_title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "محفوظ" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বুকমার্ক করা" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "gespeichert" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "bookmarked" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "marcados" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "naka-bookmark" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "enregistrés" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "שמורים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "बुकमार्क की गई" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "disimpan" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "salvati" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "保存済み" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "북마크" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "disimpan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "बुकमार्क" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "bladwijzers" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zapisane" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "marcados" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "marcados" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "закреплённые" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "bokmärken" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "புக் மார்க் செய்யப்பட்டவை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ที่คั่นไว้" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "yer imleri" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "закладені" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بک مارک شدہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đã đánh dấu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已收藏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已收藏" } } } }, "location_channels.description" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تحدث مع أشخاص قريبين عبر قنوات geohash. نشارك geohash تقريبي فقط، وليس gps الدقيق. يتم إخفاء عنوان ip لأن كل المرور يمر عبر tor." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "জিওহ্যাশ চ্যানেল দিয়ে কাছাকাছি অঞ্চলের মানুষের সঙ্গে চ্যাট করুন। শুধুই স্থূল জিওহ্যাশ শেয়ার হয়, কখনো সঠিক GPS নয়। আপনার IP টর দিয়ে সব ট্রাফিক রাউট করে লুকানো থাকে।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "chatte mit menschen in deiner nähe über geohash-kanäle. geteilt wird nur ein grober geohash, niemals exakte gps-daten. deine ip bleibt verborgen, weil der gesamte verkehr über tor läuft." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "chat with people near you using geohash channels. only a coarse geohash is shared, never exact GPS. your IP address is hidden by routing all traffic over tor." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "chatea con personas cercanas usando canales geohash. Solo se comparte un geohash aproximado, nunca GPS exacto. Tu IP se oculta al enrutar todo el tráfico por Tor." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "makipag-chat sa mga taong malapit sa iyo gamit ang mga geohash channel. coarse na geohash lang ang ibinabahagi, hindi ang eksaktong GPS. tinatago ang iyong IP address sa pagreruta ng lahat ng traffic sa tor." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "discute avec les personnes proches grâce aux canaux geohash. seul un geohash grossier est partagé, jamais de gps exact. ton ip reste cachée car tout le trafic passe par tor." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "שוחח עם אנשים קרובים בערוצי geohash. משתף רק geohash גס, אף פעם לא gps מדויק. כתובת ה-ip מוסתרת כי כל התעבורה עוברת דרך tor." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "जियोहैश चैनलों से आसपास के लोगों से चैट करें। केवल मोटा जियोहैश साझा होता है, कभी सटीक GPS नहीं। आपका IP सारा ट्रैफ़िक Tor से रूट कर छिपा रहता है।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "ngobrol dengan orang terdekat lewat kanal geohash. hanya geohash kasar yang dibagikan, tidak pernah gps tepat. alamat ip-mu tersembunyi karena seluruh trafik lewat tor." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "chatta con le persone vicine tramite canali geohash. condividiamo solo geohash approssimativi, mai il gps esatto. il tuo ip resta nascosto perché tutto il traffico passa da tor." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "geohashチャンネルで近くの人と会話。共有されるのはざっくりしたgeohashだけで正確なgpsは含みません。全トラフィックをtor経由にすることであなたのipを隠します。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "geohash 채널을 사용해 주변 사람들과 대화하세요. 정확한 GPS가 아닌 대략적인 geohash만 공유됩니다. 모든 트래픽을 tor로 라우팅하여 IP 주소를 숨깁니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "ngobrol dengan orang terdekat lewat kanal geohash. hanya geohash kasar yang dibagikan, tidak pernah gps tepat. alamat ip-mu tersembunyi karena seluruh trafik lewat tor." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "geohash च्यानलबाट नजिकका मानिससँग कुरा गर। केवल मोटामो geohash साझा हुन्छ, कहिल्यै सहि gps होइन। सबै ट्राफिक tor मार्फत गएका कारण तिम्रो ip लुकेको हुन्छ।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "chat met mensen bij jou in de buurt via geohash-kanalen. enkel een grove geohash wordt gedeeld, nooit exacte GPS. je IP-adres wordt verborgen doordat al het verkeer via tor wordt gerouteerd." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "rozmawiaj z osobami w pobliżu za pomocą kanałów geohash. udostępniany jest tylko zgrubny geohash, nigdy dokładny GPS. twój adres IP jest ukryty przez kierowanie całego ruchu przez tor." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "conversa com pessoas perto de ti usando canais geohash. apenas um geohash de baixa precisão é partilhado, nunca GPS exato. o teu IP fica oculto ao encaminhar todo o tráfego através do Tor." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "converse com pessoas próximas usando canais geohash. apenas um geohash grosseiro é compartilhado, nunca gps exato. seu ip fica oculto ao rotear todo o tráfego por tor." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "общайся с людьми рядом через каналы geohash. делится только грубый geohash, без точного gps. твой ip скрывается за счёт маршрутизации трафика через tor." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "chatta med folk nära dig via geohash-kanaler. endast en grov geohash delas, aldrig exakt GPS. din IP-adress döljs genom att all trafik går via tor." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "geohash சேனல்கள் மூலம் அருகிலுள்ளவர்களுடன் உரையாடுங்கள். மிகவும் பொது geohash மட்டுமே பகிரப்படும், துல்லியமான GPS அல்ல. உங்கள் IP, அனைத்து போக்குவரத்தையும் tor வழியாக மாற்றுவதன் மூலம் மறைக்கப்படுகிறது." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "แชทกับคนใกล้คุณผ่านช่อง geohash แบ่งปันเพียง geohash แบบหยาบ ไม่ใช่ GPS ที่แม่นยำ ที่อยู่ IP ของคุณถูกซ่อนด้วยการส่งข้อมูลผ่าน tor" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "geohash kanallarıyla yakınınızdaki insanlarla sohbet edin. yalnızca kaba bir geohash paylaşılır, tam GPS asla değil. IP adresiniz tüm trafik Tor üzerinden yönlendirilerek gizlenir." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "спілкуйся з людьми поруч у каналах geohash. передається лише грубий geohash, без точного gps. твій ip приховується, бо весь трафік йде через tor." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "قریب کے لوگوں سے گفتگو کیلئے geohash چینلز استعمال کریں۔ صرف عمومی geohash شیئر کیا جاتا ہے، کبھی درست GPS نہیں۔ آپ کا IP tor کے ذریعے ساری ٹریفک روٹ کر کے چھپایا جاتا ہے۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "trò chuyện với người gần bạn bằng các kênh geohash. chỉ chia sẻ geohash thô, không bao giờ là GPS chính xác. địa chỉ IP của bạn được ẩn bằng cách định tuyến toàn bộ lưu lượng qua tor." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "使用 geohash 频道与附近的人聊天。只会共享粗略 geohash,从不泄露精确 GPS。所有流量通过 tor 路由来隐藏你的 IP。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "使用 geohash 頻道與附近的人聊天。只會共享粗略 geohash,從不洩露精確 GPS。所有流量透過 tor 路由來隱藏你的 IP。" } } } }, "location_channels.error.invalid_geohash" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "geohash غير صالح" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "অবৈধ জিওহ্যাশ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "ungültiger geohash" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "invalid geohash" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "geohash no válido" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "di-wastong geohash" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "geohash invalide" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "geohash לא תקף" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "अमान्य जियोहैश" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "geohash tidak valid" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "geohash non valido" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "無効なgeohash" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "잘못된 geohash" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "geohash tidak valid" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "अवैध geohash" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "ongeldige geohash" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nieprawidłowy geohash" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "geohash inválido" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "geohash inválido" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "некорректный geohash" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ogiltig geohash" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "தவறான geohash" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "geohash ไม่ถูกต้อง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "geçersiz geohash" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "некоректний geohash" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "غلط geohash" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "geohash không hợp lệ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无效的 geohash" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無效的 geohash" } } } }, "location_channels.loading_nearby" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "جار البحث عن قنوات قريبة…" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "কাছাকাছি চ্যানেল খোঁজা হচ্ছে…" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "suche nach kanälen in der nähe…" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "finding nearby channels…" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "buscando canales cercanos…" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "naghahanap ng mga kalapit na channel…" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "recherche de canaux proches…" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מחפש ערוצים קרובים…" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "पास के चैनल खोजे जा रहे हैं…" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "mencari kanal sekitar…" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "ricerca canali vicini…" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "近くのチャンネルを検索中…" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "주변 채널 찾는 중…" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "mencari kanal sekitar…" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "नजिकका च्यानल खोज्दै…" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "kanalen in de buurt zoeken…" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wyszukiwanie kanałów w pobliżu…" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "a procurar canais próximos…" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "procurando canais próximos…" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "поиск каналов рядом…" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "söker efter kanaler i närheten…" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அருகிலுள்ள சேனல்கள் தேடப்படுகின்றன…" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "กำลังค้นหาช่องใกล้คุณ…" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "yakındaki kanallar aranıyor…" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "пошук каналів поруч…" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "قریب کے چینلز تلاش ہو رہے ہیں…" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đang tìm kênh gần…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在寻找附近频道…" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在尋找附近頻道…" } } } }, "location_channels.mesh_label" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "মেশ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "मेश" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "mesh" } } } }, "location_channels.permission_denied" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تم رفض إذن الموقع. فعّله في الإعدادات لاستخدام قنوات الموقع." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "লোকেশন অনুমতি অস্বীকৃত। লোকেশন চ্যানেল ব্যবহার করতে সেটিংসে সক্রিয় করুন।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "standortberechtigung verweigert. aktiviere sie in den einstellungen für standortkanäle." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "location permission denied. enable in settings to use location channels." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "permiso de ubicación denegado. Actívalo en Ajustes para usar los canales de ubicación." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "tinanggihan ang pahintulot sa lokasyon. i-enable sa settings para magamit ang mga channel ng lokasyon." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "autorisation de localisation refusée. active-la dans réglages pour utiliser les canaux." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הרשאת מיקום נדחתה. אפשר בהגדרות כדי להשתמש בערוצי מיקום." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "लोकेशन अनुमति अस्वीकृत। लोकेशन चैनल उपयोग करने के लिए सेटिंग्स में सक्षम करें।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "izin lokasi ditolak. aktifkan di pengaturan untuk memakai kanal lokasi." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "autorizzazione posizione negata. abilitala nelle impostazioni per usare i canali." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "位置情報の許可が拒否されました。チャンネルを使うには設定で許可してください。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "위치 권한이 거부되었습니다. 위치 채널을 사용하려면 설정에서 활성화하세요." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "kebenaran lokasi ditolak. aktifkan dalam Tetapan untuk menggunakan kanal lokasi." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "स्थान अनुमति अस्वीकार। स्थान च्यानल प्रयोग गर्न सेटिङमा सक्षम गर।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "locatierechten geweigerd. schakel dit in instellingen in om locatiekanalen te gebruiken." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "odmówiono dostępu do lokalizacji. włącz w ustawieniach, aby korzystać z kanałów lokalizacji." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "permissão de localização negada. ativa nas definições para usar canais de localização." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "permissão de localização negada. habilite em ajustes para usar canais de localização." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "доступ к локации запрещён. включи разрешение в настройках, чтобы использовать каналы." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "platsbehörighet nekad. aktivera i inställningar för att använda platskanaler." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இட அனுமதி மறுக்கப்பட்டது. இட சேனல்களைப் பயன்படுத்த அமைப்பில் இயக்கவும்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ปฏิเสธการเข้าถึงตำแหน่ง เปิดในตั้งค่าเพื่อใช้ช่องตำแหน่ง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "konum izni reddedildi. konum kanallarını kullanmak için ayarlardan etkinleştirin." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "доступ до локації заборонено. увімкни дозвіл у налаштуваннях, щоб користуватися каналами." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "لوکیشن کی اجازت مسترد کر دی گئی۔ لوکیشن چینلز استعمال کرنے کیلئے سیٹنگز میں فعال کریں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "bị từ chối quyền vị trí. hãy bật trong cài đặt để dùng kênh vị trí." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "位置权限被拒。请在设置中启用以使用位置频道。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "位置權限被拒。請在設定中啟用以使用位置頻道。" } } } }, "location_channels.row_title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "few" : { "stringUnit" : { "state" : "translated", "value" : "%d أشخاص" } }, "many" : { "stringUnit" : { "state" : "translated", "value" : "%d شخص" } }, "one" : { "stringUnit" : { "state" : "translated", "value" : "%d شخص" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d شخص" } }, "two" : { "stringUnit" : { "state" : "translated", "value" : "%d شخصان" } }, "zero" : { "stringUnit" : { "state" : "translated", "value" : "%d أشخاص" } } } } } } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d person" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d personen" } } } } } } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d person" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d people" } } } } } } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d persona" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d personas" } } } } } } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d personne" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d personnes" } } } } } } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "many" : { "stringUnit" : { "state" : "translated", "value" : "%d אנשים" } }, "one" : { "stringUnit" : { "state" : "translated", "value" : "%d אדם" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d אנשים" } }, "two" : { "stringUnit" : { "state" : "translated", "value" : "%d אנשים" } } } } } } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d orang" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d orang" } } } } } } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d persona" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d persone" } } } } } } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d人" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d人" } } } } } } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "other" : { "stringUnit" : { "state" : "translated", "value" : "%d명" } } } } } } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d व्यक्ति" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d व्यक्तिहरू" } } } } } } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d pessoa" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d pessoas" } } } } } } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "few" : { "stringUnit" : { "state" : "translated", "value" : "%d человека" } }, "many" : { "stringUnit" : { "state" : "translated", "value" : "%d человек" } }, "one" : { "stringUnit" : { "state" : "translated", "value" : "%d человек" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d человека" } } } } } } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "few" : { "stringUnit" : { "state" : "translated", "value" : "%d людини" } }, "many" : { "stringUnit" : { "state" : "translated", "value" : "%d людей" } }, "one" : { "stringUnit" : { "state" : "translated", "value" : "%d людина" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d людини" } } } } } } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" }, "substitutions" : { "people_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d 人" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d 人" } } } } } } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ [%2$#@people_count@]" } } } }, "location_channels.subtitle_prefix" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "#%@ • %@" } } } }, "location_channels.subtitle_with_name" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ • %2$@" } } } }, "location_channels.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "#قنوات الموقع" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "#লোকেশন চ্যানেল" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "#standort-kanäle" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "#location channels" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "#canales de ubicación" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "#mga channel ng lokasyon" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "#canaux localisation" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "#ערוצי מיקום" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "#लोकेशन चैनल" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "#kanal lokasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "#canali posizione" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "#ロケーションチャンネル" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "#위치 채널" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "#kanal lokasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "#स्थान च्यानल" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "#locatiekanalen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "#kanały lokalizacji" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "#canais de localização" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "#canais de localização" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "#каналы локации" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "#platskanaler" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "#இட சேனல்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "#ช่องตามตำแหน่ง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "#konum kanalları" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "#канали локації" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "#لوکیشن چینلز" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "#kênh vị trí" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "#位置频道" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "#位置頻道" } } } }, "location_channels.tor.subtitle" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "يخفي ip لقنوات الموقع. الموصى به: تشغيل." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "লোকেশন চ্যানেলের জন্য আপনার IP লুকায়। সুপারিশ: চালু রাখুন।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verbirgt deine ip für standortkanäle. empfohlen: an." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "hides your IP for location channels. recommended: on." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "oculta tu IP para los canales de ubicación. Recomendado: activado." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "itinatago ang iyong IP para sa mga channel ng lokasyon. inirerekomenda: naka-on." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "cache ton ip pour les canaux localisation. recommandé : activé." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מסתיר את ה-ip שלך לערוצי מיקום. מומלץ: פעיל." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "लोकेशन चैनलों के लिए आपका IP छुपाता है। अनुशंसित: चालू रखें।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "menyembunyikan ip-mu untuk kanal lokasi. disarankan: aktif." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "nasconde il tuo ip per i canali posizione. consigliato: attivo." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ロケーションチャンネル用にipを隠します。推奨: オン" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "위치 채널에서 IP를 숨깁니다. 권장: 켜기." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "menyembunyikan ip-mu untuk kanal lokasi. disarankan: aktif." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "स्थान च्यानलका लागि तिम्रो ip लुकाउँछ। सिफारिस: अन।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "verbergt je IP voor locatiekanalen. aanbevolen: aan." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "ukrywa twój IP dla kanałów lokalizacji. zalecane: włączone." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "oculta o teu IP para canais de localização. recomendado: ligado." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "oculta seu ip para canais de localização. recomendado: ligado." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "скрывает твой ip для каналов локации. рекомендуем включить." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "döljer din IP för platskanaler. rekommenderas: på." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இட சேனல்களுக்கு உங்கள் IP ஐ மறைக்கும். பரிந்துரை: இயக்கப்பட்டது." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ซ่อน IP ของคุณสำหรับช่องตำแหน่ง แนะนำให้เปิด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "konum kanalları için IP'nizi gizler. önerilen: açık." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "приховує твій ip для каналів локації. рекомендовано ввімкнути." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "لوکیشن چینلز کیلئے آپ کا IP چھپاتا ہے۔ تجویز: آن رکھیں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "ẩn IP của bạn cho kênh vị trí. khuyến nghị: bật." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "为位置频道隐藏你的 IP。推荐:开启。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "為位置頻道隱藏你的 IP。推薦:開啟。" } } } }, "location_channels.tor.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "توجيه tor" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "Tor রাউটিং" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "tor-routing" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "tor routing" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "enrutamiento Tor" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "tor routing" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "routage tor" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "ניתוב tor" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "Tor रूटिंग" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "perutean tor" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "instradamento tor" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "torルーティング" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "tor 라우팅" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "perutean tor" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "tor रूटिङ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "tor-routing" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "trasowanie tor" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "encaminhamento Tor" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "roteamento tor" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "маршрутизация tor" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "tor-routing" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "tor வழிமுறை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "การกำหนดเส้นทางผ่าน tor" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "Tor yönlendirmesi" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "маршрутизація tor" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "tor روٹنگ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "định tuyến tor" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "tor 路由" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "tor 路由" } } } }, "location_levels.block" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مربع" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্লক" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "block" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "block" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "bloque" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "bloke" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "bloc" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "בלוק" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "ब्लॉक" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "blok" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "isolato" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ブロック" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "블록" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "blok" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "ब्लक" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "blok" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "blok" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "bloco" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "quadra" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "квартал" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "block" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பிளாக்கு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "บล็อก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "blok" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "квартал" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بلاک" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "khối" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "街区" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "街區" } } } }, "location_levels.building" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مبنى" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ভবন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "gebäude" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "building" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "edificio" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "gusali" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "bâtiment" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מבנה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "भवन" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "gedung" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "edificio" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "建物" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "건물" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "gedung" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "भवन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "gebouw" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "budynek" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "edifício" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "prédio" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "здание" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "byggnad" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "கட்டிடம்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "อาคาร" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bina" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "будівля" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "عمارت" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "tòa nhà" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "楼栋" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "樓棟" } } } }, "location_levels.city" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مدينة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "শহর" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "stadt" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "city" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "ciudad" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "lungsod" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "ville" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "עיר" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "शहर" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "kota" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "città" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "都市" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "도시" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "kota" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सहर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "stad" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "miasto" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "cidade" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "cidade" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "город" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "stad" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "நகரம்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เมือง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "şehir" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "місто" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "شہر" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "thành phố" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "城市" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "城市" } } } }, "location_levels.neighborhood" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "حي" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "পাড়া" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "viertel" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "neighborhood" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "barrio" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "baranggay" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "quartier" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "שכונה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "पड़ोस" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "lingkungan" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "quartiere" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "近所" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "동네" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "lingkungan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "छिमेक" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "buurt" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "dzielnica" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "bairro" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "bairro" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "район" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "grannskap" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அயல்பகுதி" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ย่าน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "mahalle" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "район" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "محلہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "khu vực" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "社区" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "社區" } } } }, "location_levels.province" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مقاطعة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "প্রদেশ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "bundesland" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "province" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "provincia" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "probinsya" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "province" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "מחוז" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "प्रांत" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "provinsi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "provincia" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "州" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "도" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "provinsi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रदेश" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "provincie" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "województwo" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "província" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "estado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "область" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "län" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "மாவட்டம்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "จังหวัด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "il" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "область" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "صوبہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "tỉnh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "省份" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "省份" } } } }, "location_levels.region" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "منطقة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "অঞ্চল" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "region" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "region" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "región" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "rehiyon" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "région" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אזור" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "क्षेत्र" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "wilayah" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "regione" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "地域" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "지역" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "wilayah" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "क्षेत्र" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "regio" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "region" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "região" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "região" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "регион" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "region" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பிராந்தியம்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ภูมิภาค" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bölge" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "регіон" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "خطہ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "vùng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "区域" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "區域" } } } }, "location_notes.action.dismiss" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إغلاق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বন্ধ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "schließen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "dismiss" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "descartar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "isara" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "ignorer" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "סגור" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "बंद करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tutup" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "chiudi" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "閉じる" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "닫기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tutup" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "बन्द गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "sluiten" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zamknij" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "fechar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "dispensar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "закрыть" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "stäng" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "மூடு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ปิด" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kapat" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "закрити" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "بند کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đóng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "关闭" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "關閉" } } } }, "location_notes.action.retry" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "إعادة المحاولة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "আবার চেষ্টা করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "erneut versuchen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "retry" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "reintentar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "subukan muli" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "réessayer" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "ניסיון שוב" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "फिर प्रयास करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "coba lagi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "riprova" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "再試行" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "재시도" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "coba lagi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "फेरि प्रयास गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "opnieuw proberen" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "spróbuj ponownie" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "tentar novamente" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "tentar novamente" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "повторить" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "försök igen" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "மீண்டும் முயற்சி" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ลองอีกครั้ง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "yeniden dene" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "повторити" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "دوبارہ کوشش کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "thử lại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "重试" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "重試" } } } }, "location_notes.description" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "أضف ملاحظات قصيرة دائمة لهذا المكان ليجدها الآخرون." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এই স্থানে অন্যরা খুঁজে পেতে পারে এমন ছোট স্থায়ী নোট যোগ করুন।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "füge diesem ort kurze dauerhafte notizen hinzu, damit andere sie finden." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "add short permanent notes to this location for other visitors to find." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "añade notas permanentes cortas sobre este lugar para que otras personas las encuentren." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "magdagdag ng maiikling permanenteng tala sa lugar na ito para matagpuan ng iba" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "ajoute de courtes notes permanentes ici pour aider les autres." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הוסף הערות קצרות וקבועות למקום הזה כדי שאחרים ימצאו." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "इस स्थान के लिए अन्य आगंतुकों को मिलने वाले छोटे स्थायी नोट जोड़ें।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tambahkan catatan permanen singkat di tempat ini agar orang lain menemukannya." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "aggiungi brevi note permanenti su questo luogo per chi verrà." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "他の人が見つけられるようこの場所に短いノートを追加" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "다른 방문자가 볼 수 있도록 이 위치에 짧은 영구적 노트를 추가하세요." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tambahkan catatan permanen singkat di tempat ini agar orang lain menemukannya." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "अन्यले भेटून् भनी यस स्थानमा छोटो स्थायी नोट थप।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "voeg korte, blijvende notities toe aan deze locatie zodat anderen ze kunnen vinden" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "dodaj krótkie trwałe notatki o tej lokalizacji, aby inni mogli je znaleźć" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "adiciona pequenas notas permanentes a este local para outros visitantes encontrarem." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "adicione notas curtas permanentes neste local para outras pessoas encontrarem." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "добавь короткие постоянные заметки об этом месте для других." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "lägg till korta beständiga anteckningar för denna plats så att andra hittar dem" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இந்த இடத்திற்கு பிறர் கண்டுபிடிக்க சிறிய நிரந்தர குறிப்புகளைச் சேர்க்கவும்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เพิ่มบันทึกถาวรสั้น ๆ ให้สถานที่นี้เพื่อให้ผู้มาเยือนคนอื่นพบได้" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bu konuma gelen diğer ziyaretçilerin bulması için kısa kalıcı notlar ekleyin." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "додай короткі постійні замітки про це місце для інших." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "اس جگہ کیلئے مختصر مستقل نوٹس شامل کریں تاکہ دوسرے دیکھ سکیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "thêm ghi chú ngắn cố định cho nơi này để người khác tìm thấy" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "为此地点添加简短的常驻笔记,方便其他访客发现。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "為此地點添加簡短的常駐筆記,方便其他訪客發現。" } } } }, "location_notes.empty_subtitle" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "كن أول من يضيف هنا." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এই জায়গার জন্য প্রথম নোট যোগ করুন।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "sei die erste person, die hier eine notiz hinterlässt." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "be the first to add one for this spot." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "sé la primera persona en añadir una en este lugar." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "maging unang magdagdag para sa puntong ito" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "sois la première personne à en ajouter ici." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "היה הראשון להוסיף כאן." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "इस जगह के लिए पहला नोट आप जोड़ें।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "jadilah orang pertama yang menambahkannya di sini." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "fai tu la prima nota qui." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ここで最初のノートを残そう。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 장소에 첫 번째 노트를 남겨보세요." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "jadilah orang pertama yang menambahkannya di sini." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "यस ठाउँमा नोट थप्ने पहिलो व्यक्ती बन।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "wees de eerste die er hier een toevoegt" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "dodaj pierwszą notatkę dla tego miejsca" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "sê o primeiro a adicionar uma nota para este sítio." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "seja a primeira pessoa a adicionar uma aqui." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "стань первым, кто добавит здесь заметку." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "var först med en anteckning här" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இந்த இடத்திற்கு முதலில் ஒரு குறிப்பைச் சேர்க்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เป็นคนแรกที่เพิ่มบันทึกให้จุดนี้" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bu yer için ilk notu sen ekle." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "стань першим, хто додасть тут замітку." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "اس مقام کیلئے پہلا نوٹ آپ شامل کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "hãy là người đầu tiên thêm ghi chú tại đây" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "成为这里的第一条笔记。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "成為這裡的第一條筆記。" } } } }, "location_notes.empty_title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لا توجد ملاحظات بعد" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এখনও কোনো নোট নেই" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "noch keine notizen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "no notes yet" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "aún no hay notas" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "wala pang tala" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "pas encore de notes" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אין הערות עדיין" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "अभी कोई नोट नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "belum ada catatan" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "ancora nessuna nota" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ノートはまだありません" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "아직 노트가 없습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "belum ada catatan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "अहिले नोट छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "nog geen notities" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "brak notatek" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "ainda sem notas" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "nenhuma nota ainda" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "заметок пока нет" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "inga anteckningar än" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இன்னும் குறிப்புகள் இல்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ยังไม่มีบันทึก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "henüz not yok" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "заміток ще немає" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ابھی تک کوئی نوٹس نہیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "chưa có ghi chú" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "尚无笔记" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "尚無筆記" } } } }, "location_notes.error.failed_to_send" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تعذر إرسال الملاحظة. %@" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "নোট পাঠাতে ব্যর্থ। %@" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "notiz konnte nicht gesendet werden. %@" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "failed to send note. %@" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no se pudo enviar la nota. %@" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nabigong magpadala ng tala. %@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "impossible d'envoyer la note. %@" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "לא ניתן לשלוח את ההערה. %@" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "नोट भेजने में विफल। %@" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa mengirim catatan. %@" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "impossibile inviare la nota. %@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ノートを送信できませんでした。%@" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "노트 전송 실패. %@" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa menghantar catatan. %@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "नोट पठाउन सकेन। %@" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "versturen van notitie mislukt. %@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nie udało się wysłać notatki. %@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "falha ao enviar a nota. %@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "não foi possível enviar a nota. %@" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "не удалось отправить заметку. %@" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kunde inte skicka anteckning. %@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறிப்பை அனுப்ப முடியவில்லை. %@" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ส่งบันทึกล้มเหลว %@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "not gönderilemedi. %@" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "не вдалося надіслати замітку. %@" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نوٹ بھیجنا ناکام۔ %@" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "gửi ghi chú thất bại. %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法发送笔记。%@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無法發送筆記。%@" } } } }, "location_notes.error.no_relays" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لا توجد مرحلات جغرافية قريبة من هذا المكان. حاول لاحقاً." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এই স্থানের কাছে কোনো জিও রিলে নেই। কিছুক্ষণ পরে আবার চেষ্টা করুন।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "keine geo-relays in der nähe verfügbar. versuch es später erneut." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "no geo relays available near this location. try again soon." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no hay relays geográficos disponibles cerca de este lugar. Inténtalo de nuevo pronto." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "walang geo relay malapit sa lokasyong ito. subukan muli maya-maya." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "aucun relais géo disponible près d'ici. réessaie bientôt." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אין ממסרי geo זמינים בקרבת מקום. נסה שוב מאוחר יותר." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "इस स्थान के पास कोई जियो रिले उपलब्ध नहीं। कुछ देर बाद फिर प्रयास करें।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak ada relay geo tersedia dekat sini. coba lagi nanti." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "nessun relay geo disponibile qui vicino. riprova presto." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "近くに利用できるジオリレーがありません。後で再試行してください。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 위치 근처에 사용 가능한 geo 릴레이가 없습니다. 잠시 후 다시 시도하세요." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak ada relay geo tersedia dekat sini. coba lagi nanti." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "यस स्थान नजिक georelay उपलब्ध छैन। केही बेरपछि प्रयास गर।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "geen geo-relays in de buurt van deze locatie. probeer het later opnieuw." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "brak geo relay w pobliżu tej lokalizacji. spróbuj ponownie wkrótce." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "sem relés geográficos disponíveis perto deste local. tenta novamente em breve." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "nenhum relay geográfico disponível perto deste local. tente novamente em breve." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "рядом нет георелеев. попробуй позже." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "inga geo-reläer nära denna plats. försök igen senare." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இந்த இடத்துக்கு அருகில் geo relay இல்லை. கொஞ்சம் நேரம் கழித்து முயற்சி செய்யவும்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่มี geo relay ใกล้สถานที่นี้ ลองอีกครั้งในภายหลัง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bu konuma yakın geo röle yok. birazdan yeniden deneyin." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "поруч немає гео-релеїв. спробуй пізніше." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "اس جگہ کے قریب کوئی geo relay دستیاب نہیں۔ کچھ دیر بعد دوبارہ کوشش کریں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không có relay địa lý gần khu vực này. thử lại sau." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "附近没有可用的地理中继。稍后再试。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "附近沒有可用的地理中繼。稍後再試。" } } } }, "location_notes.header" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "few" : { "stringUnit" : { "state" : "translated", "value" : "%d ملاحظات" } }, "many" : { "stringUnit" : { "state" : "translated", "value" : "%d ملاحظة" } }, "one" : { "stringUnit" : { "state" : "translated", "value" : "%d ملاحظة" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d ملاحظة" } }, "two" : { "stringUnit" : { "state" : "translated", "value" : "%d ملاحظتان" } }, "zero" : { "stringUnit" : { "state" : "translated", "value" : "%d ملاحظات" } } } } } } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d notiz" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d notizen" } } } } } } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d note" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d notes" } } } } } } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d nota" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d notas" } } } } } } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d note" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d notes" } } } } } } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "many" : { "stringUnit" : { "state" : "translated", "value" : "%d הערות" } }, "one" : { "stringUnit" : { "state" : "translated", "value" : "%d הערה" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d הערות" } }, "two" : { "stringUnit" : { "state" : "translated", "value" : "%d הערות" } } } } } } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d catatan" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d catatan" } } } } } } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d nota" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d note" } } } } } } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d件のノート" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d件のノート" } } } } } } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "other" : { "stringUnit" : { "state" : "translated", "value" : "%d개의 노트" } } } } } } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d नोट" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d नोटहरू" } } } } } } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d nota" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d notas" } } } } } } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "few" : { "stringUnit" : { "state" : "translated", "value" : "%d заметки" } }, "many" : { "stringUnit" : { "state" : "translated", "value" : "%d заметок" } }, "one" : { "stringUnit" : { "state" : "translated", "value" : "%d заметка" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d заметки" } } } } } } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "few" : { "stringUnit" : { "state" : "translated", "value" : "%d замітки" } }, "many" : { "stringUnit" : { "state" : "translated", "value" : "%d заміток" } }, "one" : { "stringUnit" : { "state" : "translated", "value" : "%d замітка" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d замітки" } } } } } } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" }, "substitutions" : { "note_count" : { "argNum" : 2, "formatSpecifier" : "lld", "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%d 条笔记" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%d 条笔记" } } } } } } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "#%1$@ • %2$#@note_count@" } } } }, "location_notes.loading_notes" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "جار تحميل الملاحظات…" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "নোট লোড হচ্ছে…" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "notizen werden geladen…" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "loading notes…" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cargando notas…" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nagse-load ng mga tala…" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "chargement des notes…" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "טוען הערות…" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "नोट लोड हो रहे हैं…" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "memuat catatan…" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "caricamento note…" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ノートを読み込み中…" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "노트 로딩 중…" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "memuat catatan…" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "नोट लोड हुँदै…" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "notities laden…" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "ładowanie notatek…" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "a carregar notas…" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "carregando notas…" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "загрузка заметок…" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "laddar anteckningar…" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறிப்புகள் ஏற்றப்படுகின்றன…" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "กำลังโหลดบันทึก…" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "notlar yükleniyor…" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "завантаження заміток…" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نوٹس لوڈ ہو رہے ہیں…" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đang tải ghi chú…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在加载笔记…" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在加載筆記…" } } } }, "location_notes.loading_recent" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "جار تحميل الملاحظات الحديثة…" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "সাম্প্রতিক নোট লোড হচ্ছে…" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "aktuelle notizen werden geladen…" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "loading recent notes…" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "cargando notas recientes…" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nagse-load ng pinakahuling mga tala…" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "chargement des notes récentes…" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "טוען הערות אחרונות…" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "हाल के नोट लोड हो रहे हैं…" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "memuat catatan terbaru…" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "caricamento note recenti…" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "最新ノートを読み込み中…" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "최근 노트 로딩 중…" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "memuat catatan terbaru…" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "हालैका नोट लोड गर्दै…" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "recentste notities laden…" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "ładowanie ostatnich notatek…" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "a carregar notas recentes…" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "carregando notas recentes…" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "загрузка свежих заметок…" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "laddar senaste anteckningar…" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சமீபத்திய குறிப்புகள் ஏற்றப்படுகின்றன…" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "กำลังโหลดบันทึกล่าสุด…" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "son notlar yükleniyor…" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "завантаження свіжих заміток…" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "حالیہ نوٹس لوڈ ہو رہے ہیں…" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đang tải ghi chú gần đây…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在加载最新笔记…" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在加載最新筆記…" } } } }, "location_notes.no_relays_nearby" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لا مرحلات جغرافية قريبة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "কাছে কোনো জিও রিলে নেই" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "keine geo-relays in der nähe" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "no geo relays nearby" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no hay relays geográficos cercanos" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "walang malapit na geo relay" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "aucun relais géo à proximité" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אין ממסרי geo קרובים" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "पास कोई जियो रिले नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak ada relay geo di dekatmu" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "nessun relay geo vicino" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "近くにジオリレーなし" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "근처에 geo 릴레이가 없습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak ada relay geo di dekatmu" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "नजिक georelay छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "geen geo-relays in de buurt" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "brak pobliskich geo relay" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "sem relés geográficos por perto" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "nenhum relay geográfico próximo" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "рядом нет георелеев" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "inga geo-reläer i närheten" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அருகில் geo relay இல்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่มี geo relay ใกล้เคียง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "yakında geo röle yok" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "немає гео-релеїв поблизу" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "قریب کوئی geo relay نہیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không có relay địa lý gần đó" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "附近没有地理中继" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "附近沒有地理中繼" } } } }, "location_notes.placeholder" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "أضف ملاحظة لهذا المكان" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "এই স্থানের জন্য একটি নোট যোগ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "notiz für diesen ort hinzufügen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "add a note for this place" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "añade una nota para este lugar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "magdagdag ng tala para sa lugar na ito" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "ajoute une note pour cet endroit" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הוסף הערה למקום הזה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "इस स्थान के लिए नोट जोड़ें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tambahkan catatan untuk tempat ini" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "aggiungi una nota per questo posto" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "この場所のノートを追加" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 장소에 대한 노트 추가" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tambahkan catatan untuk tempat ini" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "यस स्थानका लागि नोट थप" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "voeg een notitie toe voor deze plek" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "dodaj notatkę do tego miejsca" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "adiciona uma nota para este local" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "adicione uma nota para este lugar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "добавь заметку для этого места" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "lägg till en anteckning för den här platsen" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இந்த இடத்திற்கான ஒரு குறிப்பைச் சேர்க்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เพิ่มบันทึกให้สถานที่นี้" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bu yer için bir not ekleyin" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "додай замітку для цього місця" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "اس جگہ کیلئے نوٹ شامل کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "thêm ghi chú cho nơi này" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "为此地点添加笔记" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "為此地點添加筆記" } } } }, "location_notes.relays_paused" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "المرحلات الجغرافية غير متاحة؛ الملاحظات متوقفة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "জিও রিলে অনুপলব্ধ; নোট স্থগিত" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "geo-relays nicht verfügbar; notizen pausiert" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "geo relays unavailable; notes paused" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "relays geográficos no disponibles; notas en pausa" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi magagamit ang mga geo relay; naka-pause ang mga tala" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "relais géo indisponibles ; notes en pause" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "ממסרי geo אינם זמינים; הערות הושהו" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "जियो रिले अनुपलब्ध; नोट रुके हैं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "relay geo tidak tersedia; catatan dijeda" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "relay geo non disponibili; note in pausa" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ジオリレーが利用不可: ノート一時停止" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "geo 릴레이를 사용할 수 없습니다; 노트가 일시 중지됩니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "relay geo tidak tersedia; catatan dijeda" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "georelay उपलब्ध छैन; नोट रोकिएको" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "geo-relays niet beschikbaar; notities gepauzeerd" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "geo relay niedostępne; notatki wstrzymane" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "relés geográficos indisponíveis; notas em pausa" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "relays geográficos indisponíveis; notas pausadas" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "геореле недоступны; заметки приостановлены" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "geo-reläer otillgängliga; anteckningar pausade" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "geo relay கிடைக்கவில்லை; குறிப்புகள் இடைநிறுத்தப்பட்டுள்ளன" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "geo relay ไม่พร้อมใช้งาน บันทึกถูกหยุดชั่วคราว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "geo röleler kullanılamıyor; notlar durduruldu" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "гео-релеї недоступні; замітки призупинено" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "geo relay دستیاب نہیں؛ نوٹس موقوف" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "relay địa lý không sẵn có; ghi chú bị tạm dừng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "地理中继不可用;笔记已暂停" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "地理中繼不可用;筆記已暫停" } } } }, "location_notes.relays_retry_hint" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "الملاحظات تعتمد على المرحلات الجغرافية. تحقق من الاتصال ثم أعد المحاولة." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "নোট জিও রিলের উপর নির্ভরশীল। সংযোগ পরীক্ষা করে আবার চেষ্টা করুন।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "notizen hängen von geo-relays ab. prüfe die verbindung und versuch es erneut." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "notes rely on geo relays. check connection and try again." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "las notas dependen de los relays geográficos. Comprueba la conexión e inténtalo de nuevo." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "umaasa ang mga tala sa mga geo relay. suriin ang koneksyon at subukan ulit." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "les notes dépendent des relais géo. vérifie la connexion et réessaie." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הערות תלויות בממסרי geo. בדוק את החיבור ונסה שוב." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "नोट जियो रिले पर निर्भर हैं। कनेक्शन जांचें और फिर प्रयास करें।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "catatan bergantung pada relay geo. cek koneksi lalu coba lagi." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "le note dipendono dai relay geo. controlla la connessione e riprova." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ノートはジオリレーに依存します。接続を確認して再試行してください。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "노트는 geo 릴레이를 사용합니다. 연결을 확인하고 다시 시도하세요." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "catatan bergantung pada relay geo. cek koneksi lalu coba lagi." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "नोट georelay मा निर्भर छन्। जडान जाँच गरेर फेरि प्रयास गर." } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "notities zijn afhankelijk van geo-relays. controleer de verbinding en probeer opnieuw." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "notatki polegają na geo relay. sprawdź połączenie i spróbuj ponownie." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "as notas dependem de relés geográficos. verifica a ligação e tenta de novo." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "notas dependem de relays geográficos. verifique a conexão e tente de novo." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "заметки зависят от георелеев. проверь подключение и попробуй снова." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "anteckningar är beroende av geo-reläer. kontrollera uppkopplingen och försök igen." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குறிப்புகள் geo relay மீது நம்புகிறது. இணைப்பைச் சரிபார்த்து மீண்டும் முயற்சிக்கவும்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "บันทึกอาศัย geo relay ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "notlar geo rölelere bağlıdır. bağlantıyı kontrol edip tekrar deneyin." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "замітки залежать від гео-релеїв. перевір з'єднання й спробуй ще раз." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نوٹس geo relay پر منحصر ہیں۔ کنکشن چیک کریں اور دوبارہ کوشش کریں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "ghi chú phụ thuộc vào relay địa lý. kiểm tra kết nối rồi thử lại." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "笔记依赖地理中继。检查连接后再试。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "筆記依賴地理中繼。檢查連線後再試。" } } } }, "mesh_peers.tooltip.new_messages" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "رسائل جديدة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "নতুন বার্তা" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "neue nachrichten" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "new messages" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "nuevos mensajes" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "may bagong mensahe" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "nouveaux messages" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הודעות חדשות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "नए संदेश" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "pesan baru" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "nuovi messaggi" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "新しいメッセージ" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 메시지" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "pesan baru" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "नयाँ सन्देश" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "nieuwe berichten" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nowe wiadomości" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "novas mensagens" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "novas mensagens" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "новые сообщения" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "nya meddelanden" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "புதிய செய்திகள்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "มีข้อความใหม่" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "yeni mesajlar" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "нові повідомлення" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نئے پیغامات" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "tin nhắn mới" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "新消息" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "新訊息" } } } }, "recording %@" : { "comment" : "Voice note recording duration indicator", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "جارٍ التسجيل %@" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "রেকর্ডিং %@" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "aufnahme %@" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "recording %@" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "grabando %@" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nagre-record %@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "enregistrement %@" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הקלטה %@" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "रिकॉर्डिंग %@" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "merekam %@" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "registrazione %@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "録音中 %@" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "녹음 중 %@" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "merakam %@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "रेकर्डिङ %@" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "opname %@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nagrywanie %@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "a gravar %@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "gravando %@" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "идет запись %@" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "spelar in %@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பதிவு %@" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "กำลังบันทึก %@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kaydediliyor %@" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "йде запис %@" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ریکارڈنگ %@" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đang ghi %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "录音中 %@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "錄音中 %@" } } } }, "save" : { "comment" : "Button to save media to device", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "حفظ" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "সংরক্ষণ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "speichern" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "save" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "guardar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-save" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "enregistrer" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "שמור" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "सहेजें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "simpan" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "salva" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "保存" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "저장" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "simpan" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "सेभ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "opslaan" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zapisz" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "guardar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "salvar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "сохранить" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "spara" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சேமிக்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "บันทึก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kaydet" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "зберегти" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "محفوظ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "lưu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "保存" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "儲存" } } } }, "system.chat.blocked" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لا يمكن بدء دردشة مع %@: المستخدم محظور." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%@-এর সঙ্গে চ্যাট শুরু করা যায় না: ব্যক্তি ব্লক করা আছে।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "chat mit %@ kann nicht gestartet werden: nutzer blockiert." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "cannot start chat with %@: person is blocked." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no se puede iniciar un chat con %@: el usuario está bloqueado." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi makapagsimula ng chat kay %@: na-block ang tao." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "impossible de démarrer un chat avec %@ : utilisateur bloqué." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "לא ניתן להתחיל צ'אט עם %@: המשתמש חסום." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%@ के साथ चैट शुरू नहीं कर सकते: व्यक्ति ब्लॉक है।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa mulai chat dengan %@: pengguna diblokir." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "impossibile avviare una chat con %@: utente bloccato." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@とはチャットできません: ユーザーをブロック中。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%@와 채팅을 시작할 수 없습니다: 차단된 사용자입니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa mulai chat dengan %@: pengguna diblokir." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%@ सँग च्याट सुरु गर्न मिलेन: प्रयोगकर्ता ब्लक गरिएको" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "kan chat met %@ niet starten: persoon is geblokkeerd." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nie można rozpocząć czatu z %@: osoba zablokowana." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "não é possível iniciar o chat com %@: a pessoa está bloqueada." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "não é possível iniciar chat com %@: usuário bloqueado." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "нельзя начать чат с %@: пользователь заблокирован." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kan inte starta chatt med %@: personen är blockerad." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%@ உடன் உரையாடல் தொடங்க முடியாது: அந்த நபர் தடுக்கப்பட்டுள்ளார்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่สามารถเริ่มแชทกับ %@: บุคคลนี้ถูกบล็อก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%@ ile sohbet başlatılamıyor: kişi engelli." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "не можна почати чат з %@: користувач заблокований." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%@ کے ساتھ چیٹ شروع نہیں کر سکتے: شخص بلاک ہے۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không thể bắt đầu chat với %@: người này bị chặn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法与 %@ 开始聊天:用户已被屏蔽。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無法與 %@ 開始聊天:使用者已被屏蔽。" } } } }, "system.chat.requires_favorite" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لا يمكن بدء دردشة مع %@: يجب أن تكونا مفضلين متبادلين للتشغيل بدون اتصال." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%@-এর সঙ্গে চ্যাট শুরু করা যায় না: অফলাইন মেসেজিংয়ের জন্য পারস্পরিক প্রিয় দরকার।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "chat mit %@ kann nicht gestartet werden: gegenseitige favoriten für offline nötig." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "cannot start chat with %@: mutual favorite required for offline messaging." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no se puede iniciar un chat con %@: necesitas ser favoritos mutuos para mensajería sin conexión." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi makapagsimula ng chat kay %@: kailangan ang mutual na paborito para sa offline na mensahe." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "impossible de démarrer un chat avec %@ : favoris mutuels requis pour le hors ligne." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "לא ניתן להתחיל צ'אט עם %@: נדרשים מועדפים הדדיים לאופליין." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%@ के साथ चैट शुरू नहीं कर सकते: ऑफ़लाइन मैसेजिंग के लिए आपसी पसंदीदा आवश्यक है।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa mulai chat dengan %@: butuh favorit bersama untuk offline." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "impossibile avviare una chat con %@: servono preferiti reciproci per l'offline." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@とはチャットできません: オフラインには相互のお気に入りが必要です。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%@와 채팅을 시작할 수 없습니다: 오프라인 메시지를 보내려면 서로 즐겨찾기에 추가해야 합니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa mulai chat dengan %@: butuh favorit bersama untuk offline." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%@ सँग च्याट सुरु गर्न मिलेन: अफलाइनका लागि दुवै मनपर्ने हुनुपर्छ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "kan chat met %@ niet starten: wederzijds favoriet vereist voor offline berichten." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nie można rozpocząć czatu z %@: potrzebne wzajemne ulubione dla wiadomości offline." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "não é possível iniciar o chat com %@: precisam de ser favoritos mútuos para mensagens offline." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "não é possível iniciar chat com %@: vocês precisam ser favoritos mútuos para mensagens offline." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "нельзя начать чат с %@: нужны взаимные избранные для офлайна." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kan inte starta chatt med %@: ömsesidig favorit krävs för offline-meddelanden." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%@ உடன் உரையாடல் தொடங்க முடியாது: ஆஃப்லைன் செய்திகளுக்கு இருபுறமும் பிரியப்பட்டவர்கள் ஆக வேண்டும்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่สามารถเริ่มแชทกับ %@: ต้องเป็นรายการโปรดทั้งสองฝ่ายเพื่อใช้งานออฟไลน์" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%@ ile sohbet başlatılamıyor: çevrimdışı mesajlaşma için karşılıklı favori gerekiyor." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "не можна почати чат з %@: потрібне взаємне вибране для офлайна." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%@ کے ساتھ چیٹ شروع نہیں کر سکتے: آفلائن پیغامات کیلئے باہمی پسندیدہ ہونا ضروری ہے۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không thể bắt đầu chat với %@: cần yêu thích lẫn nhau để nhắn ngoại tuyến." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法与 %@ 开始聊天:离线消息需要互相关注。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無法與 %@ 開始聊天:離線訊息需要互相關注。" } } } }, "system.common.user" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "مستخدم" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ব্যবহারকারী" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "nutzer" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "user" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "usuario" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "user" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "utilisateur" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "משתמש" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "उपयोगकर्ता" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "pengguna" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "utente" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ユーザー" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "사용자" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "pengguna" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रयोगकर्ता" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "gebruiker" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "użytkownik" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "utilizador" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "usuário" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "пользователь" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "användare" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பயனர்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ผู้ใช้" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "kullanıcı" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "користувач" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "صارف" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "người dùng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "用户" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "使用者" } } } }, "system.dm.blocked_generic" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تعذر الإرسال: المستخدم محظور." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বার্তা পাঠানো যায় না: ব্যক্তি ব্লক করা আছে।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "senden nicht möglich: nutzer blockiert." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "cannot send message: person is blocked." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no se puede enviar el mensaje: el usuario está bloqueado." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi makapagpadala ng mensahe: na-block ang tao." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "envoi impossible : utilisateur bloqué." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אי אפשר לשלוח: המשתמש חסום." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "संदेश नहीं भेज सकते: व्यक्ति ब्लॉक है।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa mengirim: pengguna diblokir." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "invio non riuscito: utente bloccato." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "送信できません: ユーザーをブロック中。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "메시지를 보낼 수 없습니다: 차단된 사용자입니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa menghantar: pengguna diblokir." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "पठाउन मिलेन: प्रयोगकर्ता ब्लक" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "kan bericht niet sturen: persoon is geblokkeerd." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nie można wysłać wiadomości: osoba zablokowana." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "não é possível enviar mensagem: a pessoa está bloqueada." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "não foi possível enviar: usuário bloqueado." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "отправка невозможна: пользователь заблокирован." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kan inte skicka meddelande: personen är blockerad." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "செய்தி அனுப்ப முடியவில்லை: நபர் தடுக்கப்பட்டுள்ளார்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่สามารถส่งข้อความ: บุคคลนี้ถูกบล็อก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "mesaj gönderilemiyor: kişi engelli." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "не вдалося надіслати: користувач заблокований." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "پیغام نہیں بھیج سکتے: شخص بلاک ہے۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không thể gửi tin: người này bị chặn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法发送:用户已被屏蔽。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無法發送:使用者已被屏蔽。" } } } }, "system.dm.blocked_recipient" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لا يمكن الإرسال إلى %@: المستخدم محظور." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%@-কে বার্তা পাঠানো যায় না: ব্যক্তি ব্লক করা আছে।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "senden an %@ nicht möglich: nutzer blockiert." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "cannot send message to %@: person is blocked." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no se puede enviar un mensaje a %@: el usuario está bloqueado." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi maipadala kay %@: na-block ang tao." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "impossible d'envoyer à %@ : utilisateur bloqué." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אי אפשר לשלוח ל-%@: המשתמש חסום." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%@ को संदेश नहीं भेज सकते: व्यक्ति ब्लॉक है।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa mengirim ke %@: pengguna diblokir." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "impossibile inviare a %@: utente bloccato." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@に送れません: ユーザーをブロック中。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%@에게 메시지를 보낼 수 없습니다: 차단된 사용자입니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa menghantar ke %@: pengguna diblokir." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%@ लाई पठाउन मिलेन: प्रयोगकर्ता ब्लक" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "kan geen bericht sturen naar %@: persoon is geblokkeerd." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nie można wysłać do %@: osoba zablokowana." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "não é possível enviar mensagem a %@: a pessoa está bloqueada." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "não é possível enviar mensagem para %@: usuário bloqueado." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "нельзя отправить %@: пользователь заблокирован." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kan inte skicka till %@: personen är blockerad." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%@ க்கு செய்தி அனுப்ப முடியவில்லை: நபர் தடுக்கப்பட்டுள்ளார்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่สามารถส่งข้อความถึง %@: บุคคลนี้ถูกบล็อก" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%@'a mesaj gönderilemiyor: kişi engelli." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "неможливо надіслати %@: користувач заблокований." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%@ کو پیغام نہیں بھیج سکتے: شخص بلاک ہے۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không thể gửi tin cho %@: người này bị chặn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法向 %@ 发送:用户已被屏蔽。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無法向 %@ 發送:使用者已被屏蔽。" } } } }, "system.dm.unreachable" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لا يمكن الإرسال إلى %@: المستلم غير متاح عبر mesh أو nostr." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%@-কে বার্তা পাঠানো যায় না - পিয়ার মেশ বা নোস্টরে পৌঁছানো যাচ্ছে না।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "senden an %@ nicht möglich: empfänger über mesh oder nostr nicht erreichbar." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "cannot send message to %@ - peer is not reachable via mesh or nostr." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no se puede enviar un mensaje a %@: el destinatario no es alcanzable por mesh ni Nostr." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi maipadala kay %@ - hindi maabot ang peer sa mesh o nostr." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "impossible d'envoyer à %@ : destinataire injoignable via mesh ou nostr." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אי אפשר לשלוח ל-%@: הנמען אינו נגיש דרך mesh או nostr." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%@ को संदेश नहीं भेज सकते - पीयर मेश या नोस्ट्र पर पहुँच योग्य नहीं।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa mengirim ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "impossibile inviare a %@: destinatario irraggiungibile via mesh o nostr." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@に送れません: 受信者はmeshやnostrで到達できません。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%@에게 메시지를 보낼 수 없습니다 - mesh 또는 nostr를 통해 피어에 연결할 수 없습니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak bisa menghantar ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%@ लाई पठाउन मिलेन: प्राप्तकर्ता mesh वा nostr बाट उपलब्ध छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "kan geen bericht sturen naar %@ – peer niet bereikbaar via mesh of nostr." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nie można wysłać do %@ – peer nieosiągalny przez mesh ani nostr." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "não é possível enviar mensagem a %@ - o par não está acessível por mesh ou Nostr." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "não é possível enviar mensagem para %@: destinatário inalcançável por mesh ou nostr." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "нельзя отправить %@: адресат недоступен через mesh или nostr." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kan inte skicka till %@ – peer nås inte via mesh eller nostr." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%@ க்கு செய்தி அனுப்ப முடியவில்லை - peer mesh அல்லது nostr மூலமாக அணுக முடியவில்லை." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่สามารถส่งข้อความถึง %@ - ติดต่อเพียร์ผ่าน mesh หรือ nostr ไม่ได้" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%@'a mesaj gönderilemiyor - eş mesh veya Nostr üzerinden ulaşılamıyor." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "неможливо надіслати %@: одержувач недосяжний через mesh або nostr." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%@ کو پیغام نہیں بھیج سکتے - peer mesh یا nostr کے ذریعے دستیاب نہیں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không thể gửi tin cho %@ - nút không thể liên lạc qua mesh hoặc nostr." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法向 %@ 发送:对方无法通过 mesh 或 Nostr 到达。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無法向 %@ 發送:對方無法透過 mesh 或 Nostr 到達。" } } } }, "system.geohash.blocked" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تم حظر %@ في محادثات geohash" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "জিওহ্যাশ চ্যাটে %@ ব্লক করা হয়েছে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%@ wurde in geohash-chats blockiert" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "blocked %@ in geohash chats" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "se bloqueó a %@ en los chats geohash" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "na-block si %@ sa geohash chats" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a été bloqué dans les chats geohash" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "%@ נחסם בצ'אטי geohash" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "जियोहैश चैट में %@ ब्लॉक किया गया" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "%@ diblokir di chat geohash" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "%@ è stato bloccato nei chat geohash" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@をgeohashチャットでブロックしました" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "geohash 채팅에서 %@을(를) 차단했습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "%@ diblokir di chat geohash" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%@ लाई geohash च्याटमा ब्लक गरियो" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "%@ geblokkeerd in geohash-chats" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zablokowano %@ w czatach geohash" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "%@ bloqueado nos chats geohash" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "%@ foi bloqueado nos chats geohash" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "%@ заблокирован в geohash-чатах" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "blockerade %@ i geohash-chattar" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "geohash உரையாடல்களில் %@ தடுக்கப்பட்டார்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "บล็อก %@ ในแชท geohash" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "geohash sohbetlerinde %@ engellendi" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "%@ заблоковано в geohash-чатах" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "geohash چیٹس میں %@ کو بلاک کیا" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đã chặn %@ trong chat geohash" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已在 geohash 聊天中屏蔽 %@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已在 geohash 聊天中屏蔽 %@" } } } }, "system.geohash.unblocked" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تم إلغاء حظر %@ في محادثات geohash" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "জিওহ্যাশ চ্যাটে %@ আনব্লক করা হয়েছে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%@ wurde in geohash-chats entsperrt" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "unblocked %@ in geohash chats" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "se desbloqueó a %@ en los chats geohash" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "in-unblock si %@ sa geohash chats" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a été débloqué dans les chats geohash" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "%@ הוסר מהחסימה בצ'אטי geohash" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "जियोहैश चैट में %@ अनब्लॉक किया गया" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "%@ dibuka blokirnya di chat geohash" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "%@ è stato sbloccato nei chat geohash" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@のgeohashチャットでのブロックを解除しました" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "geohash 채팅에서 %@의 차단을 해제했습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "%@ dibuka blokirnya di chat geohash" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%@ लाई geohash च्याटमा अनब्लक गरियो" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "%@ gedeblokkeerd in geohash-chats" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "odblokowano %@ w czatach geohash" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "%@ desbloqueado nos chats geohash" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "%@ foi desbloqueado nos chats geohash" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "%@ разблокирован в geohash-чатах" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "avblockerade %@ i geohash-chattar" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "geohash உரையாடல்களில் %@ தடை நீக்கப்பட்டார்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เลิกบล็อก %@ ในแชท geohash" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "geohash sohbetlerinde %@ engeli kaldırıldı" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "%@ розблоковано в geohash-чатах" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "geohash چیٹس میں %@ کا بلاک ہٹایا" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đã bỏ chặn %@ trong chat geohash" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已在 geohash 聊天中解除屏蔽 %@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已在 geohash 聊天中解除屏蔽 %@" } } } }, "system.location.not_in_channel" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تعذر الإرسال: لست داخل قناة موقع" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "পাঠানো যায় না: লোকেশন চ্যানেলে নেই" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "senden fehlgeschlagen: du bist nicht in einem standortkanal" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "cannot send: not in a location channel" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no se puede enviar: no estás en un canal de ubicación" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "hindi maipadala: wala ka sa channel ng lokasyon" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "envoi impossible : tu n'es pas dans un canal localisation" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אי אפשר לשלוח: אינך בערוץ מיקום" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "भेज नहीं सकते: लोकेशन चैनल में नहीं हैं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "gagal mengirim: kamu tidak berada di kanal lokasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "invio fallito: non sei in un canale posizione" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "送信失敗: ロケーションチャンネルに参加していません" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "전송할 수 없음: 위치 채널에 있지 않습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "gagal menghantar: kamu tidak berada di kanal lokasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "पठाउन मिलेन: तिमी स्थान च्यानलमा छैनौ" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "kan niet versturen: je zit niet in een locatiekanaal" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nie można wysłać: nie jesteś w kanale lokalizacji" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "não foi possível enviar: não estás num canal de localização" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "não foi possível enviar: você não está em um canal de localização" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "отправка невозможна: ты не в канале локации" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kan inte skicka: du är inte i ett platskanal" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "அனுப்ப முடியாது: நீங்கள் இட சேனலில் இல்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ส่งไม่ได้: คุณไม่ได้อยู่ในช่องตำแหน่ง" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "gönderilemiyor: konum kanalında değilsiniz" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "не вдалося надіслати: ти не в каналі локації" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "نہیں بھیج سکتے: آپ لوکیشن چینل میں نہیں ہیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không gửi được: bạn không ở trong kênh vị trí" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "发送失败:你不在位置频道中" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "發送失敗:你不在位置頻道中" } } } }, "system.location.send_failed" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تعذر الإرسال إلى قناة الموقع" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "লোকেশন চ্যানেলে পাঠাতে ব্যর্থ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "konnte nicht an den standortkanal senden" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "failed to send to location channel" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no se pudo enviar al canal de ubicación" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nabigong magpadala sa channel ng lokasyon" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "envoi au canal localisation impossible" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "השליחה לערוץ המיקום נכשלה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "लोकेशन चैनल पर भेजना विफल" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "gagal mengirim ke kanal lokasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "impossibile inviare al canale posizione" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ロケーションチャンネルに送信できませんでした" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "위치 채널로 전송에 실패했습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "gagal menghantar ke kanal lokasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "स्थान च्यानलमा पठाउन सकेन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "versturen naar locatiekanaal mislukt" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wysłanie do kanału lokalizacji nie powiodło się" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "falha ao enviar para o canal de localização" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "não foi possível enviar para o canal de localização" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "не удалось отправить в канал локации" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "kunde inte skicka till platskanalen" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இட சேனலுக்கு அனுப்புதல் தோல்வியடைந்தது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ส่งไปยังช่องตำแหน่งล้มเหลว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "konum kanalına gönderme başarısız" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "не вдалося надіслати в канал локації" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "لوکیشن چینل کو بھیجنا ناکام رہا" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "gửi tới kênh vị trí thất bại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法发送到位置频道" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "無法發送到位置頻道" } } } }, "system.tor.dev_bypass" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "بناء تطوير: تجاوز tor مفعل." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ডেভেলপমেন্ট বিল্ড: Tor বাইপাস সক্রিয়।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "dev-build: tor-bypass aktiv." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "development build: Tor bypass enabled." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "compilación de desarrollo: bypass de Tor activado." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "developer build: naka-enable ang Tor bypass." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "build de développement : bypass tor actif." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "בנייה לפיתוח: עקיפת tor פעילה." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "डेवलपमेंट बिल्ड: Tor बायपास सक्षम।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "build pengembangan: bypass tor aktif." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "build di sviluppo: bypass tor attivo." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "開発ビルド: torバイパスが有効です。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 빌드: Tor 우회가 활성화되었습니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "build pengembangan: bypass tor aktif." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "डेभ बिल्ड: tor बाइपास सक्षम।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "ontwikkelaarsbuild: Tor-bypass ingeschakeld." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wersja deweloperska: obejście Tor włączone." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "compilação de desenvolvimento: bypass do Tor ativo." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "compilação de desenvolvimento: bypass de tor ativo." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "dev-сборка: обход tor включён." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "utvecklarbuild: Tor-bypass aktiverad." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "மேம்பாட்டு கட்டிடம்: Tor பயாபாஸ் செயல்படுத்தப்பட்டது." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "รุ่นสำหรับนักพัฒนา: เปิดใช้งานการข้าม tor" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "geliştirme yapısı: Tor atlatması etkin." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "dev-збірка: обхід tor увімкнено." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ڈیولپر بلڈ: Tor بائی پاس فعال ہے۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "bản dành cho dev: đang bật bỏ qua Tor." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "开发构建:tor 绕过已启用。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "開發版本:tor 繞過已啟用。" } } } }, "system.tor.restarted" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "tor أُعيد تشغيله. تمت استعادة التوجيه." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "Tor পুনরায় চালু হয়েছে। নেটওয়ার্ক রাউটিং পুনরুদ্ধার হয়েছে।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "tor wurde neu gestartet. routing wiederhergestellt." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "tor restarted. network routing restored." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "tor se reinició. Se restauró el enrutamiento de la red." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nag-restart ang tor. naibalik ang routing ng network." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "tor a redémarré. routage restauré." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "tor הופעל מחדש. הניתוב שוחזר." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "Tor पुनः आरंभ हो गया। नेटवर्क रूटिंग बहाल।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tor dimulai ulang. perutean dipulihkan." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "tor è stato riavviato. instradamento ripristinato." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "torを再起動しました。ルーティングを復旧。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "tor가 다시 시작되었습니다. 네트워크 라우팅이 복원되었습니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tor dimulai ulang. perutean dipulihkan." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "tor फेरि सुरु भयो। रूटिङ पुनःस्थापित।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "tor opnieuw gestart. netwerkrouting hersteld." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "tor uruchomiony ponownie. trasowanie przywrócone." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "O Tor foi reiniciado. O encaminhamento de rede foi restaurado." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "tor reiniciou. roteamento restaurado." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "tor перезапущен. маршрутизация восстановлена." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "tor har startats om. nätverksroutingen återställd." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "tor மீண்டும் தொடங்கியுள்ளது. நெட்வொர்க் வழிமுறை மீட்டெடுக்கப்பட்டது." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "tor เริ่มใหม่ เส้นทางเครือข่ายกลับมาแล้ว" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "Tor yeniden başlatıldı. ağ yönlendirmesi geri geldi." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "tor перезапущено. маршрутизацію відновлено." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "tor نے دوبارہ آغاز کر دیا۔ نیٹ ورک روٹنگ بحال ہو گئی۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "tor đã khởi động lại. định tuyến mạng được khôi phục." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "tor 已重启。网络路由已恢复。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "tor 已重啟。網路路由已恢復。" } } } }, "system.tor.restarting" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "tor يعاد تشغيله لاستعادة الاتصال..." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "Tor সংযোগ ফিরিয়ে আনতে পুনরায় চালু হচ্ছে..." } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "tor startet neu, um die verbindung herzustellen..." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "tor restarting to recover connectivity..." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "tor se está reiniciando para recuperar la conectividad..." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nagre-restart ang tor upang ibalik ang koneksyon..." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "tor redémarre pour rétablir la connectivité..." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "tor מופעל מחדש להשבת החיבור..." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "कनेक्टिविटी बहाल करने के लिए Tor पुनः आरंभ हो रहा है..." } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tor sedang dimulai ulang untuk memulihkan konektivitas..." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "tor si sta riavviando per ripristinare la connettività..." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "接続回復のためtorを再起動しています..." } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "연결 복구를 위해 tor를 다시 시작하는 중..." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tor sedang dimulai ulang untuk memulihkan konektivitas..." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "tor जडान फर्काउन पुनः सुरु हुँदैछ..." } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "tor wordt opnieuw gestart om verbinding te herstellen..." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "tor uruchamia się ponownie, aby przywrócić łączność..." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "O Tor está a reiniciar para recuperar a conectividade..." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "tor está reiniciando para recuperar conectividade..." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "tor перезапускается, чтобы вернуть связь..." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "tor startas om för att återställa anslutningen..." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "இணைப்பை மீட்டெடுக்க tor மீண்டும் தொடங்குகிறது..." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "tor กำลังเริ่มใหม่เพื่อกู้คืนการเชื่อมต่อ..." } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "Tor bağlantıyı kurtarmak için yeniden başlatılıyor..." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "tor перезапускається, щоб відновити підключення..." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "tor کنکشن بحال کرنے کیلئے دوبارہ شروع ہو رہا ہے..." } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "tor đang khởi động lại để phục hồi kết nối..." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "tor 正在重启以恢复连接..." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "tor 正在重啟以恢復連線..." } } } }, "system.tor.started" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "tor يعمل. كل الدردشة تمر عبر tor للخصوصية." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "Tor চালু হয়েছে। IP গোপন রাখতে সব চ্যাট Tor দিয়ে যাচ্ছে।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "tor läuft. der gesamte chat wird über tor geleitet." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "tor started. routing all chats via tor for IP privacy." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "tor se inició. Todo el chat se enruta por Tor para privacidad." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "nagsimula ang tor. lahat ng chat ay dumadaan sa tor para itago ang IP." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "tor a démarré. tout le chat passe par tor pour la confidentialité." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "tor פעיל. כל הצ'אט עובר דרך tor לפרטיות." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "Tor शुरू हो गया। IP गोपनीयता के लिए सभी चैट Tor से रूट हो रहे हैं।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tor berjalan. seluruh chat dirutekan lewat tor demi privasi." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "tor è avviato. tutta la chat passa da tor per la privacy." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "torを起動しました。全チャットをtor経由で配信します。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "tor가 시작되었습니다. IP 보호를 위해 모든 대화를 tor를 통해 라우팅합니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tor berjalan. seluruh chat dirutekan lewat tor demi privasi." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "tor सुरु भयो। गोपनीयताका लागि पूरा च्याट tor मार्फत जान्छ।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "tor gestart. alle chats gaan via tor voor IP-privacy." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "tor uruchomiony. wszystkie czaty idą przez tor dla prywatności IP." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "O Tor foi iniciado. Todas as conversas são encaminhadas via Tor para ocultar o IP." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "tor iniciou. todo o chat é roteado por tor para privacidade." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "tor запущен. весь чат идёт через tor для приватности." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "tor startad. all chatt routas via tor för IP-integritet." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "tor தொடங்கியுள்ளது. IP பாதுகாப்பிற்காக அனைத்து உரையாடலும் tor வழியாக செல்கிறது." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "tor เริ่มทำงานแล้ว ส่งแชททั้งหมดผ่าน tor เพื่อปกปิด IP" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "Tor başlatıldı. IP gizliliği için tüm sohbetler Tor üzerinden yönlendiriliyor." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "tor запущено. увесь чат іде через tor для приватності." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "tor شروع ہو گیا۔ IP راز داری کیلئے تمام چیٹس tor سے گزرتی ہیں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "tor đã khởi động. toàn bộ chat đi qua tor để ẩn IP." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "tor 已启动。所有聊天通过 tor 路由以保护 IP。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "tor 已啟動。所有聊天透過 tor 路由以保護 IP。" } } } }, "system.tor.starting" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "يتم تشغيل tor..." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "Tor চালু হচ্ছে..." } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "tor wird gestartet..." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "starting tor..." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "iniciando Tor..." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "sinisimulan ang tor..." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "lancement de tor..." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "tor מופעל..." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "Tor शुरू हो रहा है..." } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "menjalankan tor..." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "avvio tor..." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "torを起動中..." } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "tor 시작 중..." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "menjalankan tor..." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "tor सुरु हुँदै..." } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "tor wordt gestart..." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "uruchamianie tor..." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "O Tor está a iniciar..." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "iniciando tor..." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "запуск tor..." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "tor startar..." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "tor தொடங்குகிறது..." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "กำลังเริ่ม tor..." } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "Tor başlatılıyor..." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "запуск tor..." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "tor شروع ہو رہا ہے..." } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đang khởi động tor..." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在启动 tor..." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在啟動 tor..." } } } }, "verification.my_qr.accessibility_label" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "رمز qr للتحقق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "যাচাই QR কোড" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verifizierungs-qr-code" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "verification QR code" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "código QR de verificación" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "QR code para sa beripikasyon" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "code qr de vérification" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "קוד qr לאימות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "सत्यापन QR कोड" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "kode qr verifikasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "codice qr di verifica" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "検証用qrコード" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "인증 QR 코드" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "kode qr verifikasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रमाणीकरण qr कोड" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "QR-code voor verificatie" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "kod QR do weryfikacji" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "código QR de verificação" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "código qr de verificação" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "qr-код проверки" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "QR-kod för verifiering" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சரிபார்ப்பு QR குறியீடு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "รหัส QR สำหรับยืนยัน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "doğrulama QR kodu" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "qr-код підтвердження" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "تصدیقی QR کوڈ" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "mã QR xác minh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "验证 QR 码" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "驗證 QR 碼" } } } }, "verification.my_qr.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "امسح للتحقق مني" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "আমাকে যাচাই করতে স্ক্যান করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "scanne, um mich zu verifizieren" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "scan to verify me" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "escanea para verificarme" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-scan para i-verify ako" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "scanne pour me vérifier" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "סרוק כדי לאמת" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "मुझे सत्यापित करने के लिए स्कैन करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "pindai untuk verifikasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "scansiona per verificarmi" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "スキャンして確認" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "스캔해서 인증하세요" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "pindai untuk verifikasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "मलाई प्रमाणित गर्न स्क्यान गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "scan om mij te verifiëren" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zeskanuj, aby mnie zweryfikować" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "Digitaliza para me verificares" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "escaneie para me verificar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "отсканируй, чтобы подтвердить меня" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "skanna för att verifiera mig" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "என்னைச் சரிபார்க்க ஸ்கேன் செய்யவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "สแกนเพื่อยืนยันตัวฉัน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "beni doğrulamak için tara" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "скануй, щоб підтвердити мене" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "مجھے تصدیق کرنے کیلئے اسکین کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "quét để xác minh tôi" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "扫描验证我" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "掃描驗證我" } } } }, "verification.my_qr.unavailable" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "qr غير متاح" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "QR অনুপলব্ধ" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "qr nicht verfügbar" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "QR unavailable" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "QR no disponible" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "walang QR" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "qr indisponible" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "qr לא זמין" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "QR उपलब्ध नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "qr tidak tersedia" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "qr non disponibile" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "qrは利用不可" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "QR 사용 불가" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "qr tidak tersedia" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "qr उपलब्ध छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "QR niet beschikbaar" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "QR niedostępny" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "QR indisponível" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "qr indisponível" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "qr недоступен" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "QR ej tillgänglig" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "QR கிடைக்கவில்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่มี QR" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "QR mevcut değil" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "qr недоступний" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "QR دستیاب نہیں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "QR không khả dụng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "QR 不可用" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "QR 不可用" } } } }, "verification.scan.paste_prompt" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "الصق محتوى qr للتحقق:" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "যাচাই করতে QR বিষয়বস্তু পেস্ট করুন:" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "füge den qr-inhalt zum prüfen ein:" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "paste QR content to validate:" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "pega el contenido del QR para validarlo:" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-paste ang nilalaman ng QR para beripikahin:" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "colle le contenu du qr pour valider :" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הדבק תוכן qr לאימות:" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "सत्यापित करने के लिए QR सामग्री पेस्ट करें:" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tempel konten qr untuk validasi:" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "incolla il contenuto del qr per convalidare:" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "確認するqr内容を貼り付け:" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "인증을 위해 QR 내용을 붙여넣으세요:" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tempel konten qr untuk validasi:" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रमाणित गर्न qr सामग्री पेस्ट गर:" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "plak QR-inhoud om te valideren:" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "wklej zawartość QR, aby zweryfikować:" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "Cola o conteúdo do QR para validar:" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "cole o conteúdo do qr para validar:" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "вставь содержимое qr для проверки:" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "klistra in QR-innehåll för att validera:" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சரிபார்க்க QR உள்ளடக்கத்தை ஒட்டு:" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "วางเนื้อหา QR เพื่อยืนยัน:" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "doğrulamak için QR içeriğini yapıştırın:" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "встав вміст qr для перевірки:" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "تصدیق کیلئے QR مواد چسپاں کریں:" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "dán nội dung QR để kiểm tra:" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "粘贴 QR 内容以验证:" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "粘貼 QR 內容以驗證:" } } } }, "verification.scan.prompt_friend" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "امسح qr لصديق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "বন্ধুর QR স্ক্যান করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "scanne den qr eines freundes" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "scan a friend's QR" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "escanea el QR de un amigo" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "i-scan ang QR ng kaibigan" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "scanne le qr d'un ami" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "סרוק qr של חבר" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "मित्र का QR स्कैन करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "pindai qr teman" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "scansiona il qr di un amico" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "友達のqrをスキャン" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "친구의 QR 스캔하기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "pindai qr teman" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "साथीको qr स्क्यान गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "scan de QR van een vriend" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zeskanuj QR znajomego" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "Digitaliza o QR de um amigo" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "escaneie o qr de um amigo" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "отсканируй qr друга" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "skanna en väns QR" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "நண்பரின் QR ஐ ஸ்கேன் செய்யவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "สแกน QR ของเพื่อน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "bir arkadaşının QR'ını tara" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "скануй qr друга" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "دوست کا QR اسکین کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "quét QR của bạn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "扫描好友的 QR" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "掃描好友的 QR" } } } }, "verification.scan.status.invalid" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "qr غير صالح أو منتهٍ" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "অবৈধ বা মেয়াদোত্তীর্ণ QR পেলোড" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "qr ungültig oder abgelaufen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "invalid or expired QR payload" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "QR inválido o caducado" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "di-wasto o paso na ang QR payload" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "qr invalide ou expiré" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "qr לא תקף או שפג תוקפו" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "अमान्य या समाप्त हुआ QR पेलोड" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "qr tidak valid atau kedaluwarsa" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "qr non valido o scaduto" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "qrが無効または期限切れ" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "QR 페이로드가 잘못되었거나 만료되었습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "qr tidak valid atau kedaluwarsa" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "qr अवैध या म्याद सकिएको" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "ongeldige of verlopen QR-payload" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nieprawidłowy lub wygasły payload QR" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "payload de QR inválido ou expirado" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "qr inválido ou expirado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "qr недействителен или просрочен" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "ogiltig eller utgången QR-payload" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சரியானதல்ல அல்லது காலாவதியான QR payload" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "payload ของ QR ไม่ถูกต้องหรือหมดอายุ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "geçersiz veya süresi dolmuş QR yükü" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "qr недійсний або прострочений" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "غلط یا میعاد ختم شدہ QR payload" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "payload QR không hợp lệ hoặc đã hết hạn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "QR 无效或已过期" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "QR 無效或已過期" } } } }, "verification.scan.status.no_peer" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "لم يتم العثور على قرين مطابق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "মিলে যাওয়া পিয়ার খুঁজে পাওয়া যায়নি" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "kein passender peer gefunden" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "could not find matching peer" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "no se encontró un peer coincidente" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "walang nahanap na kaakibat na peer" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "aucun pair correspondant trouvé" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "לא נמצא עמית תואם" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "मिलता-जुलता पीयर नहीं मिला" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "tidak ada peer yang cocok" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "nessun peer corrispondente trovato" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "該当するピアが見つかりません" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "일치하는 피어를 찾을 수 없습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "tidak ada peer yang cocok" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "मिल्ने peer फेला परेन" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "geen overeenkomende peer gevonden" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "nie znaleziono dopasowanego peera" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "não foi possível encontrar o par correspondente" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "nenhum peer correspondente encontrado" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "соответствующий пир не найден" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "hittade ingen matchande peer" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "பொருந்தும் peer கிடைக்கவில்லை" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ไม่พบเพียร์ที่ตรงกัน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "eş bulunamadı" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "відповідний пір не знайдений" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ملتا جلتا peer نہیں ملا" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "không tìm thấy nút phù hợp" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "未找到匹配的同伴" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "未找到匹配的同伴" } } } }, "verification.scan.status.requested" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تم طلب التحقق لـ %@" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "%@-এর জন্য যাচাই অনুরোধ করা হয়েছে" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "verifizierung für %@ angefordert" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "verification requested for %@" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "se solicitó la verificación de %@" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "humiling ng beripikasyon para kay %@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "vérification demandée pour %@" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "התבקש אימות עבור %@" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "%@ के लिए सत्यापन अनुरोध किया गया" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "verifikasi diminta untuk %@" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "verifica richiesta per %@" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%@の検証をリクエストしました" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "%@에 대한 인증이 요청되었습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "verifikasi diminta untuk %@" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "%@ को लागि प्रमाणीकरण अनुरोध भयो" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "verificatie aangevraagd voor %@" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "poproszono o weryfikację %@" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "verificação solicitada para %@" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "verificação solicitada para %@" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "проверка запрошена для %@" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "verifiering begärd för %@" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "%@ க்கான சரிபார்ப்பு கோரப்பட்டுள்ளது" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ร้องขอยืนยันสำหรับ %@" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "%@ için doğrulama istendi" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "перевірка запитана для %@" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "%@ کیلئے تصدیق کی درخواست کی گئی" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "đã yêu cầu xác minh cho %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已请求 %@ 的验证" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已請求 %@ 的驗證" } } } }, "verification.scan.validate" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تحقق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "যাচাই করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "prüfen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "validate" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "validar" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "beripikahin" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "valider" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אשר" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "सत्यापित करें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "validasi" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "convalida" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "確認" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "인증하기" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "validasi" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रमाणित गर" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "verifiëren" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "zweryfikuj" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "validar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "validar" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "проверить" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "verifiera" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சரிபார்க்க" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ยืนยัน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "doğrula" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "перевірити" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "تصدیق کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "xác minh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "验证" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "驗證" } } } }, "verification.sheet.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "تحقق" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "যাচাই" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "VERIFIZIEREN" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "VERIFY" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "VERIFICAR" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "BERIPIKA" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "VÉRIFIER" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "אימות" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "सत्यापन" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "VERIFIKASI" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "VERIFICA" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "確認" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "인증" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "VERIFIKASI" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "प्रमाणित" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "VERIFICATIE" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "WERYFIKACJA" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "VERIFICAR" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "VERIFICAR" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "ПРОВЕРИТЬ" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "VERIFIERA" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "சரிபார்ப்பு" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "ยืนยัน" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "DOĞRULA" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "ПЕРЕВІРИТИ" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "تصدیق" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "XÁC MINH" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "验证" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "驗證" } } } }, "Voice notes are only available in mesh chats." : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "الملاحظات الصوتية متاحة فقط في محادثات الميش." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ভয়েস নোট শুধু মেশ চ্যাটে উপলব্ধ।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sprachnachrichten sind nur im Mesh-Chat verfügbar." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Voice notes are only available in mesh chats." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "Las notas de voz solo están disponibles en los chats de mesh." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "Ang mga voice note ay available lamang sa mga mesh chat." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Les notes vocales sont uniquement disponibles dans les discussions mesh." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "הערות קוליות זמינות רק בצ׳אט של mesh." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "वॉइस नोट्स केवल मेश चैट में ही उपलब्ध हैं।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Catatan suara hanya tersedia di obrolan mesh." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "Le note vocali sono disponibili solo nelle chat mesh." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ボイスメモはメッシュチャットでのみ利用できます。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "음성 메모는 메쉬 채팅에서만 사용할 수 있습니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "Nota suara hanya tersedia dalam sembang mesh." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "भ्वाइस नोटहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Spraaknotities zijn alleen beschikbaar in mesh-chats." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "Notatki głosowe są dostępne tylko na czatach mesh." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "As notas de voz só estão disponíveis nos chats mesh." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "As mensagens de voz só estão disponíveis nos chats mesh." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "Голосовые сообщения доступны только в mesh-чатах." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "Röstanteckningar är bara tillgängliga i mesh-chattar." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "குரல் குறிப்புகள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "บันทึกเสียงใช้งานได้เฉพาะในแชต mesh เท่านั้น" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "Sesli notlar yalnızca mesh sohbetlerinde kullanılabilir." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "Голосові нотатки доступні лише в mesh-чатах." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "وائس نوٹس صرف میش چیٹس میں دستیاب ہیں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "Ghi chú giọng nói chỉ khả dụng trong các cuộc trò chuyện mesh." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "语音消息仅可在 mesh 聊天中使用。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "語音訊息僅能在 mesh 聊天中使用。" } } } }, "Images are only available in mesh chats." : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "الصور متاحة فقط في محادثات الميش." } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "ছবি শুধু মেশ চ্যাটে উপলব্ধ।" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Bilder sind nur im Mesh-Chat verfügbar." } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Images are only available in mesh chats." } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "Las imágenes solo están disponibles en los chats de mesh." } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "Ang mga larawan ay available lamang sa mga mesh chat." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Les images sont uniquement disponibles dans les discussions mesh." } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "תמונות זמינות רק בצ׳אט של mesh." } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "चित्र केवल मेश चैट में ही उपलब्ध हैं।" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Gambar hanya tersedia di obrolan mesh." } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "Le immagini sono disponibili solo nelle chat mesh." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "画像はメッシュチャットでのみ利用できます。" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "이미지는 메쉬 채팅에서만 사용할 수 있습니다." } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "Imej hanya tersedia dalam sembang mesh." } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Afbeeldingen zijn alleen beschikbaar in mesh-chats." } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "Obrazy są dostępne tylko na czatach mesh." } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "As imagens só estão disponíveis nos chats mesh." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "As imagens só estão disponíveis nos chats mesh." } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "Изображения доступны только в mesh-чатах." } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "Bilder är bara tillgängliga i mesh-chattar." } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்." } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "Görseller yalnızca mesh sohbetlerinde kullanılabilir." } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "Зображення доступні лише в mesh-чатах." } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "تصاویر صرف میش چیٹس میں دستیاب ہیں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "图片仅可在 mesh 聊天中使用。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "圖片僅能在 mesh 聊天中使用。" } } } }, "Choose an image" : { "comment" : "A label displayed above a button that allows the user to choose an image to send.", "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", "value" : "اختر صورة" } }, "bn" : { "stringUnit" : { "state" : "translated", "value" : "একটি ছবি নির্বাচন করুন" } }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Bild auswählen" } }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Choose an image" } }, "es" : { "stringUnit" : { "state" : "translated", "value" : "Elige una imagen" } }, "fil" : { "stringUnit" : { "state" : "translated", "value" : "Pumili ng larawan" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir une image" } }, "he" : { "stringUnit" : { "state" : "translated", "value" : "בחר תמונה" } }, "hi" : { "stringUnit" : { "state" : "translated", "value" : "एक चित्र चुनें" } }, "id" : { "stringUnit" : { "state" : "translated", "value" : "Pilih gambar" } }, "it" : { "stringUnit" : { "state" : "translated", "value" : "Scegli un’immagine" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "画像を選択" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "이미지를 선택하세요" } }, "ms" : { "stringUnit" : { "state" : "translated", "value" : "Pilih imej" } }, "ne" : { "stringUnit" : { "state" : "translated", "value" : "एउटा तस्वीर चयन गर्नुहोस्" } }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Kies een afbeelding" } }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "Wybierz obraz" } }, "pt" : { "stringUnit" : { "state" : "translated", "value" : "Escolher uma imagem" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Escolha uma imagem" } }, "ru" : { "stringUnit" : { "state" : "translated", "value" : "Выберите изображение" } }, "sv" : { "stringUnit" : { "state" : "translated", "value" : "Välj en bild" } }, "ta" : { "stringUnit" : { "state" : "translated", "value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", "value" : "เลือกภาพ" } }, "tr" : { "stringUnit" : { "state" : "translated", "value" : "Bir görüntü seç" } }, "uk" : { "stringUnit" : { "state" : "translated", "value" : "Виберіть зображення" } }, "ur" : { "stringUnit" : { "state" : "translated", "value" : "ایک تصویر منتخب کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", "value" : "Chọn một hình ảnh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "选择图像" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "選擇圖像" } } } } }, "version" : "1.1" } ================================================ FILE: bitchat/Models/BitchatMessage.swift ================================================ // // BitchatMessage.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation /// Represents a user-visible message in the BitChat system. /// Handles both broadcast messages and private encrypted messages, /// with support for mentions, replies, and delivery tracking. /// - Note: This is the primary data model for chat messages final class BitchatMessage: Codable { let id: String let sender: String let content: String let timestamp: Date let isRelay: Bool let originalSender: String? let isPrivate: Bool let recipientNickname: String? let senderPeerID: PeerID? let mentions: [String]? // Array of mentioned nicknames var deliveryStatus: DeliveryStatus? // Delivery tracking // Cached formatted text (not included in Codable) private var _cachedFormattedText: [String: AttributedString] = [:] func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? { return _cachedFormattedText["\(isDark)-\(isSelf)"] } func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) { _cachedFormattedText["\(isDark)-\(isSelf)"] = text } // Codable implementation enum CodingKeys: String, CodingKey { case id, sender, content, timestamp, isRelay, originalSender case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus } init( id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: PeerID? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil ) { self.id = id ?? UUID().uuidString self.sender = sender self.content = content self.timestamp = timestamp self.isRelay = isRelay self.originalSender = originalSender self.isPrivate = isPrivate self.recipientNickname = recipientNickname self.senderPeerID = senderPeerID self.mentions = mentions self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil) } } // MARK: - Equatable Conformance extension BitchatMessage: Equatable { static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool { return lhs.id == rhs.id && lhs.sender == rhs.sender && lhs.content == rhs.content && lhs.timestamp == rhs.timestamp && lhs.isRelay == rhs.isRelay && lhs.originalSender == rhs.originalSender && lhs.isPrivate == rhs.isPrivate && lhs.recipientNickname == rhs.recipientNickname && lhs.senderPeerID == rhs.senderPeerID && lhs.mentions == rhs.mentions && lhs.deliveryStatus == rhs.deliveryStatus } } // MARK: - Binary encoding extension BitchatMessage { func toBinaryPayload() -> Data? { var data = Data() // Message format: // - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions) // - Timestamp: 8 bytes (seconds since epoch) // - ID length: 1 byte // - ID: variable // - Sender length: 1 byte // - Sender: variable // - Content length: 2 bytes // - Content: variable // Optional fields based on flags: // - Original sender length + data // - Recipient nickname length + data // - Sender peer ID length + data // - Mentions array var flags: UInt8 = 0 if isRelay { flags |= 0x01 } if isPrivate { flags |= 0x02 } if originalSender != nil { flags |= 0x04 } if recipientNickname != nil { flags |= 0x08 } if senderPeerID != nil { flags |= 0x10 } if mentions != nil && !mentions!.isEmpty { flags |= 0x20 } data.append(flags) // Timestamp (in milliseconds) let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000) // Encode as 8 bytes, big-endian for i in (0..<8).reversed() { data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF)) } // ID if let idData = id.data(using: .utf8) { data.append(UInt8(min(idData.count, 255))) data.append(idData.prefix(255)) } else { data.append(0) } // Sender if let senderData = sender.data(using: .utf8) { data.append(UInt8(min(senderData.count, 255))) data.append(senderData.prefix(255)) } else { data.append(0) } // Content if let contentData = content.data(using: .utf8) { let length = UInt16(min(contentData.count, 65535)) // Encode length as 2 bytes, big-endian data.append(UInt8((length >> 8) & 0xFF)) data.append(UInt8(length & 0xFF)) data.append(contentData.prefix(Int(length))) } else { data.append(contentsOf: [0, 0]) } // Optional fields if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) { data.append(UInt8(min(origData.count, 255))) data.append(origData.prefix(255)) } if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) { data.append(UInt8(min(recipData.count, 255))) data.append(recipData.prefix(255)) } if let peerData = senderPeerID?.id.data(using: .utf8) { data.append(UInt8(min(peerData.count, 255))) data.append(peerData.prefix(255)) } // Mentions array if let mentions = mentions { data.append(UInt8(min(mentions.count, 255))) // Number of mentions for mention in mentions.prefix(255) { if let mentionData = mention.data(using: .utf8) { data.append(UInt8(min(mentionData.count, 255))) data.append(mentionData.prefix(255)) } else { data.append(0) } } } return data } convenience init?(_ data: Data) { // Create an immutable copy to prevent threading issues let dataCopy = Data(data) guard dataCopy.count >= 13 else { return nil } var offset = 0 // Flags guard offset < dataCopy.count else { return nil } let flags = dataCopy[offset]; offset += 1 let isRelay = (flags & 0x01) != 0 let isPrivate = (flags & 0x02) != 0 let hasOriginalSender = (flags & 0x04) != 0 let hasRecipientNickname = (flags & 0x08) != 0 let hasSenderPeerID = (flags & 0x10) != 0 let hasMentions = (flags & 0x20) != 0 // Timestamp guard offset + 8 <= dataCopy.count else { return nil } let timestampData = dataCopy[offset.. 0 { mentions = [] for _ in 0.. [Element] { let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } guard arr.count > 1 else { return arr } var seen = Set() var dedup: [BitchatMessage] = [] for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) { if !seen.contains(m.id) { dedup.append(m) seen.insert(m.id) } } return dedup } } ================================================ FILE: bitchat/Models/BitchatPacket.swift ================================================ // // BitchatPacket.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation /// The core packet structure for all BitChat protocol messages. /// Encapsulates all data needed for routing through the mesh network, /// including TTL for hop limiting and optional encryption. /// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented struct BitchatPacket: Codable { let version: UInt8 let type: UInt8 let senderID: Data let recipientID: Data? let timestamp: UInt64 let payload: Data var signature: Data? var ttl: UInt8 var route: [Data]? var isRSR: Bool init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) { self.version = version self.type = type self.senderID = senderID self.recipientID = recipientID self.timestamp = timestamp self.payload = payload self.signature = signature self.ttl = ttl self.route = route self.isRSR = isRSR } // Convenience initializer for new binary format init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) { self.version = 1 self.type = type // Convert hex string peer ID to binary data (8 bytes) var senderData = Data() var tempID = senderID.id while tempID.count >= 2 { let hexByte = String(tempID.prefix(2)) if let byte = UInt8(hexByte, radix: 16) { senderData.append(byte) } tempID = String(tempID.dropFirst(2)) } self.senderID = senderData self.recipientID = nil self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds self.payload = payload self.signature = nil self.ttl = ttl self.route = nil self.isRSR = isRSR } var data: Data? { BinaryProtocol.encode(self) } func toBinaryData(padding: Bool = true) -> Data? { BinaryProtocol.encode(self, padding: padding) } // Backward-compatible helper (defaults to padded encoding) func toBinaryData() -> Data? { toBinaryData(padding: true) } /// Create binary representation for signing (without signature and TTL fields) /// TTL is excluded because it changes during packet relay operations func toBinaryDataForSigning() -> Data? { // Create a copy without signature and with fixed TTL for signing // TTL must be excluded because it changes during relay let unsignedPacket = BitchatPacket( type: type, senderID: senderID, recipientID: recipientID, timestamp: timestamp, payload: payload, signature: nil, // Remove signature for signing ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility version: version, route: route, isRSR: false // RSR flag is mutable and not part of the signature ) return BinaryProtocol.encode(unsignedPacket) } static func from(_ data: Data) -> BitchatPacket? { BinaryProtocol.decode(data) } } ================================================ FILE: bitchat/Models/BitchatPeer.swift ================================================ import Foundation import CoreBluetooth /// Represents a peer in the BitChat network with all associated metadata struct BitchatPeer: Equatable { let peerID: PeerID // Hex-encoded peer ID let noisePublicKey: Data let nickname: String let lastSeen: Date let isConnected: Bool let isReachable: Bool // Favorite-related properties var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship? // Nostr identity (if known) var nostrPublicKey: String? // Connection state enum ConnectionState { case bluetoothConnected case meshReachable // Seen via mesh recently, not directly connected case nostrAvailable // Mutual favorite, reachable via Nostr case offline // Not connected via any transport } var connectionState: ConnectionState { if isConnected { return .bluetoothConnected } else if isReachable { return .meshReachable } else if favoriteStatus?.isMutual == true { // Mutual favorites can communicate via Nostr when offline return .nostrAvailable } else { return .offline } } var isFavorite: Bool { favoriteStatus?.isFavorite ?? false } var isMutualFavorite: Bool { favoriteStatus?.isMutual ?? false } var theyFavoritedUs: Bool { favoriteStatus?.theyFavoritedUs ?? false } // Display helpers var displayName: String { nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname } var statusIcon: String { switch connectionState { case .bluetoothConnected: return "📻" // Radio icon for mesh connection case .meshReachable: return "📡" // Antenna for mesh reachable case .nostrAvailable: return "🌐" // Purple globe for Nostr case .offline: if theyFavoritedUs && !isFavorite { return "🌙" // Crescent moon - they favorited us but we didn't reciprocate } else { return "" } } } // Initialize from mesh service data init( peerID: PeerID, noisePublicKey: Data, nickname: String, lastSeen: Date = Date(), isConnected: Bool = false, isReachable: Bool = false ) { self.peerID = peerID self.noisePublicKey = noisePublicKey self.nickname = nickname self.lastSeen = lastSeen self.isConnected = isConnected self.isReachable = isReachable // Load favorite status - will be set later by the manager self.favoriteStatus = nil self.nostrPublicKey = nil } static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool { lhs.peerID == rhs.peerID } } ================================================ FILE: bitchat/Models/CommandInfo.swift ================================================ // // CommandsInfo.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation // MARK: - CommandInfo Enum enum CommandInfo: String, Identifiable { case block case clear case hug case message = "dm" case slap case unblock case who case favorite case unfavorite var id: String { rawValue } var alias: String { "/" + rawValue } var placeholder: String? { switch self { case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: return "<" + String(localized: "content.input.nickname_placeholder") + ">" case .clear, .who: return nil } } var description: String { switch self { case .block: String(localized: "content.commands.block") case .clear: String(localized: "content.commands.clear") case .hug: String(localized: "content.commands.hug") case .message: String(localized: "content.commands.message") case .slap: String(localized: "content.commands.slap") case .unblock: String(localized: "content.commands.unblock") case .who: String(localized: "content.commands.who") case .favorite: String(localized: "content.commands.favorite") case .unfavorite: String(localized: "content.commands.unfavorite") } } static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who] if isGeoPublic || isGeoDM { return baseCommands + [.favorite, .unfavorite] } return baseCommands } } ================================================ FILE: bitchat/Models/MessagePadding.swift ================================================ // // MessagePadding.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation /// Provides privacy-preserving message padding to obscure actual content length. /// Uses PKCS#7-style padding with random bytes to prevent traffic analysis. struct MessagePadding { // Standard block sizes for padding static let blockSizes = [256, 512, 1024, 2048] // Add PKCS#7-style padding to reach target size static func pad(_ data: Data, toSize targetSize: Int) -> Data { guard data.count < targetSize else { return data } let paddingNeeded = targetSize - data.count // Constrain to 255 to fit a single-byte pad length marker guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data } var padded = data // PKCS#7: All pad bytes are equal to the pad length padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded)) return padded } // Remove padding from data static func unpad(_ data: Data) -> Data { guard !data.isEmpty else { return data } let last = data.last! let paddingLength = Int(last) // Must have at least 1 pad byte and not exceed data length guard paddingLength > 0 && paddingLength <= data.count else { return data } // Verify PKCS#7: all last N bytes equal to pad length let start = data.count - paddingLength let tail = data[start...] for b in tail { if b != last { return data } } return Data(data[.. Int { // Account for encryption overhead (~16 bytes for AES-GCM tag) let totalSize = dataSize + 16 // Find smallest block that fits for blockSize in blockSizes { if totalSize <= blockSize { return blockSize } } // For very large messages, just use the original size // (will be fragmented anyway) return dataSize } } ================================================ FILE: bitchat/Models/NoisePayload.swift ================================================ // // NoisePayload.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation /// Helper to create typed Noise payloads struct NoisePayload { let type: NoisePayloadType let data: Data /// 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) } } ================================================ FILE: bitchat/Models/PeerID.swift ================================================ // // PeerID.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation struct PeerID: Equatable, Hashable { enum Prefix: String, CaseIterable { /// When no prefix is provided case empty = "" /// `"mesh:"` case mesh = "mesh:" /// `"name:"` case name = "name:" /// `"noise:"` (+ 64 characters hex) case noise = "noise:" /// `"nostr_"` (+ 16 characters hex) case geoDM = "nostr_" /// `"nostr:"` (+ 8 characters hex) case geoChat = "nostr:" } let prefix: Prefix /// Returns the actual value without any prefix let bare: String /// Returns the full `id` value by combining `(prefix + bare)` var id: String { prefix.rawValue + bare } // Private so the callers have to go through a convenience init private init(prefix: Prefix, bare: any StringProtocol) { self.prefix = prefix self.bare = String(bare).lowercased() } } // MARK: - Convenience Inits extension PeerID { /// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey` init(nostr_ pubKey: String) { self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength)) } /// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey` init(nostr pubKey: String) { self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength)) } /// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts init(str: any StringProtocol) { if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) { self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count)) } else { self.init(prefix: .empty, bare: str) } } /// Convenience init to handle `Optional` init?(str: (any StringProtocol)?) { guard let str else { return nil } self.init(str: str) } /// Convenience init to create PeerID by converting Data to String init?(data: Data) { self.init(str: String(data: data, encoding: .utf8)) } /// Convenience init to "hide" hex-encoding implementation detail init(hexData: Data) { self.init(str: hexData.hexEncodedString()) } /// Convenience init to "hide" hex-encoding implementation detail init?(hexData: Data?) { guard let hexData else { return nil } self.init(hexData: hexData) } } // MARK: - Noise Public Key Helpers extension PeerID { /// Derive the stable 16-hex peer ID from a Noise static public key init(publicKey: Data) { self.init(str: publicKey.sha256Fingerprint().prefix(16)) } /// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed func toShort() -> PeerID { if let noiseKey { return PeerID(publicKey: noiseKey) } return self } } // MARK: - Codable extension PeerID: Codable { init(from decoder: any Decoder) throws { self.init(str: try decoder.singleValueContainer().decode(String.self)) } func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() try container.encode(id) } } // MARK: - Helpers extension PeerID { var isEmpty: Bool { id.isEmpty } /// Returns true if `id` starts with "`nostr:`" var isGeoChat: Bool { prefix == .geoChat } /// Returns true if `id` starts with "`nostr_`" var isGeoDM: Bool { prefix == .geoDM } func toPercentEncoded() -> String { id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id } } extension PeerID { var routingData: Data? { if let direct = Data(hexString: id), direct.count == 8 { return direct } if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData } let short = toShort() return Data(hexString: short.id) } init?(routingData: Data) { guard routingData.count == 8 else { return nil } self.init(hexData: routingData) } } // MARK: - Validation extension PeerID { private enum Constants { static let maxIDLength = 64 static let hexIDLength = 16 // 8 bytes = 16 hex chars } /// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64) var isValid: Bool { if prefix != .empty { return PeerID(str: bare).isValid } // Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex) if isShort || isNoiseKeyHex { return true } // If length equals short or full but isn't valid hex, reject if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength { return false } // Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64) let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) return !id.isEmpty && id.count < Constants.maxIDLength && id.rangeOfCharacter(from: validCharset.inverted) == nil } /// Returns true if the `bare` id is all hex var isHex: Bool { bare.allSatisfy { $0.isHexDigit } } /// Short routing IDs (exact 16-hex) var isShort: Bool { bare.count == Constants.hexIDLength && isHex } /// Full Noise key hex (exact 64-hex) var isNoiseKeyHex: Bool { noiseKey != nil } /// Full Noise key (exact 64-hex) as Data var noiseKey: Data? { guard bare.count == Constants.maxIDLength else { return nil } return Data(hexString: bare) } } // MARK: - Comparable extension PeerID: Comparable { static func < (lhs: PeerID, rhs: PeerID) -> Bool { lhs.id < rhs.id } } // MARK: - CustomStringConvertible extension PeerID: CustomStringConvertible { /// So it returns the actual `id` like before even inside another String var description: String { id } } ================================================ FILE: bitchat/Models/ReadReceipt.swift ================================================ // // ReadReceipt.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation struct ReadReceipt: Codable { let originalMessageID: String let receiptID: String var readerID: PeerID // Who read it let readerNickname: String let timestamp: Date init(originalMessageID: String, readerID: PeerID, readerNickname: String) { self.originalMessageID = originalMessageID self.receiptID = UUID().uuidString self.readerID = readerID self.readerNickname = readerNickname self.timestamp = Date() } // For binary decoding private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) { self.originalMessageID = originalMessageID self.receiptID = receiptID self.readerID = readerID self.readerNickname = readerNickname self.timestamp = timestamp } func encode() -> Data? { try? JSONEncoder().encode(self) } static func decode(from data: Data) -> ReadReceipt? { try? JSONDecoder().decode(ReadReceipt.self, from: data) } // MARK: - Binary Encoding func toBinaryData() -> Data { var data = Data() data.appendUUID(originalMessageID) data.appendUUID(receiptID) // ReaderID as 8-byte hex string var readerData = Data() var tempID = readerID.id while tempID.count >= 2 && readerData.count < 8 { let hexByte = String(tempID.prefix(2)) if let byte = UInt8(hexByte, radix: 16) { readerData.append(byte) } tempID = String(tempID.dropFirst(2)) } while readerData.count < 8 { readerData.append(0) } data.append(readerData) data.appendDate(timestamp) data.appendString(readerNickname) return data } static func fromBinaryData(_ data: Data) -> ReadReceipt? { // Create defensive copy let dataCopy = Data(data) // Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname guard dataCopy.count >= 49 else { return nil } var offset = 0 guard let originalMessageID = dataCopy.readUUID(at: &offset), let receiptID = dataCopy.readUUID(at: &offset) else { return nil } guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } let readerID = PeerID(hexData: readerIDData) guard readerID.isValid else { return nil } guard let timestamp = dataCopy.readDate(at: &offset), InputValidator.validateTimestamp(timestamp), let readerNicknameRaw = dataCopy.readString(at: &offset), let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil } return ReadReceipt(originalMessageID: originalMessageID, receiptID: receiptID, readerID: readerID, readerNickname: readerNickname, timestamp: timestamp) } } ================================================ FILE: bitchat/Models/RequestSyncPacket.swift ================================================ import Foundation // REQUEST_SYNC payload TLV (type, length16, value) // - 0x01: P (uint8) — Golomb-Rice parameter // - 0x02: M (uint32, big-endian) — hash range (N * 2^P) // - 0x03: data (opaque) — GR bitstream bytes (MSB-first) struct RequestSyncPacket { let p: Int let m: UInt32 let data: Data let types: SyncTypeFlags? let sinceTimestamp: UInt64? let fragmentIdFilter: String? init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) { self.p = p self.m = m self.data = data self.types = types self.sinceTimestamp = sinceTimestamp self.fragmentIdFilter = fragmentIdFilter } func encode() -> Data { var out = Data() func putTLV(_ t: UInt8, _ v: Data) { out.append(t) let len = UInt16(v.count) out.append(UInt8((len >> 8) & 0xFF)) out.append(UInt8(len & 0xFF)) out.append(v) } // P putTLV(0x01, Data([UInt8(p & 0xFF)])) // M (uint32) var mBE = m.bigEndian putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) }) // data putTLV(0x03, data) if let typesData = types?.toData() { putTLV(0x04, typesData) } if let ts = sinceTimestamp { var tsBE = ts.bigEndian putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) }) } if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) { putTLV(0x06, fidData) } return out } static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? { var off = 0 var p: Int? = nil var m: UInt32? = nil var payload: Data? = nil var types: SyncTypeFlags? = nil var sinceTimestamp: UInt64? = nil var fragmentIdFilter: String? = nil while off + 3 <= data.count { let t = Int(data[off]); off += 1 guard off + 2 <= data.count else { return nil } let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2 guard off + len <= data.count else { return nil } let v = data.subdata(in: off..<(off+len)); off += len switch t { case 0x01: if v.count == 1 { p = Int(v[0]) } case 0x02: if v.count == 4 { var mm: UInt32 = 0 for b in v { mm = (mm << 8) | UInt32(b) } m = mm } case 0x03: if v.count > maxAcceptBytes { return nil } payload = v case 0x04: if let decoded = SyncTypeFlags.decode(v) { types = decoded } case 0x05: if v.count == 8 { var ts: UInt64 = 0 for b in v { ts = (ts << 8) | UInt64(b) } sinceTimestamp = ts } case 0x06: if let fid = String(data: v, encoding: .utf8) { fragmentIdFilter = fid } default: break // forward compatible; ignore unknown TLVs } } guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil } return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter) } } ================================================ FILE: bitchat/Noise/NoiseProtocol.swift ================================================ // // NoiseProtocol.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // /// /// # NoiseProtocol /// /// A complete implementation of the Noise Protocol Framework for end-to-end /// encryption in BitChat. This file contains the core cryptographic primitives /// and handshake logic that enable secure communication between peers. /// /// ## Overview /// The Noise Protocol Framework is a modern cryptographic framework designed /// for building secure protocols. BitChat uses Noise to provide: /// - Mutual authentication between peers /// - Forward secrecy for all messages /// - Protection against replay attacks /// - Minimal round trips for connection establishment /// /// ## Implementation Details /// This implementation follows the Noise specification exactly, using: /// - **Pattern**: XX (most versatile, provides mutual authentication) /// - **DH**: Curve25519 (X25519 key exchange) /// - **Cipher**: ChaCha20-Poly1305 (AEAD encryption) /// - **Hash**: SHA-256 (for key derivation and authentication) /// /// ## Security Properties /// The XX handshake pattern provides: /// 1. **Identity Hiding**: Both parties' identities are encrypted /// 2. **Forward Secrecy**: Past sessions remain secure if keys are compromised /// 3. **Key Compromise Impersonation Resistance**: Compromised static key doesn't allow impersonation to that party /// 4. **Mutual Authentication**: Both parties verify each other's identity /// /// ## Handshake Flow (XX Pattern) /// ``` /// Initiator Responder /// --------- --------- /// -> e (ephemeral key) /// <- e, ee, s, es (ephemeral, DH, static encrypted, DH) /// -> s, se (static encrypted, DH) /// ``` /// /// ## Key Components /// - **NoiseCipherState**: Manages symmetric encryption with nonce tracking /// - **NoiseSymmetricState**: Handles key derivation and handshake hashing /// - **NoiseHandshakeState**: Orchestrates the complete handshake process /// /// ## Replay Protection /// Implements sliding window replay protection to prevent message replay attacks: /// - Tracks nonces within a 1024-message window /// - Rejects duplicate or too-old nonces /// - Handles out-of-order message delivery /// /// ## Usage Example /// ```swift /// let handshake = NoiseHandshakeState( /// pattern: .XX, /// role: .initiator, /// localStatic: staticKeyPair /// ) /// let messageBuffer = handshake.writeMessage(payload: Data()) /// // Send messageBuffer to peer... /// ``` /// /// ## Security Considerations /// - Static keys must be generated using secure random sources /// - Keys should be stored securely (e.g., in Keychain) /// - Handshake state must not be reused after completion /// - Transport messages have a nonce limit (2^64-1) /// /// ## References /// - Noise Protocol Framework: http://www.noiseprotocol.org/ /// - Noise Specification: http://www.noiseprotocol.org/noise.html /// import BitLogger import Foundation import CryptoKit // Core Noise Protocol implementation // Based on the Noise Protocol Framework specification // MARK: - Constants and Types /// Supported Noise handshake patterns. /// Each pattern provides different security properties and authentication guarantees. enum NoisePattern { case XX // Most versatile, mutual authentication case IK // Initiator knows responder's static key case NK // Anonymous initiator } enum NoiseRole { case initiator case responder } enum NoiseMessagePattern { case e // Ephemeral key case s // Static key case ee // DH(ephemeral, ephemeral) case es // DH(ephemeral, static) case se // DH(static, ephemeral) case ss // DH(static, static) } // MARK: - Noise Protocol Configuration struct NoiseProtocolName { let pattern: String let dh: String = "25519" // Curve25519 let cipher: String = "ChaChaPoly" // ChaCha20-Poly1305 let hash: String = "SHA256" // SHA-256 var fullName: String { "Noise_\(pattern)_\(dh)_\(cipher)_\(hash)" } } // MARK: - Cipher State /// Manages symmetric encryption state for Noise protocol sessions. /// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management /// and replay protection using a sliding window algorithm. /// - Warning: Nonce reuse would be catastrophic for security final class NoiseCipherState { // Constants for replay protection private static let NONCE_SIZE_BYTES = 4 private static let REPLAY_WINDOW_SIZE = 1024 private static let REPLAY_WINDOW_BYTES = REPLAY_WINDOW_SIZE / 8 // 128 bytes private static let HIGH_NONCE_WARNING_THRESHOLD: UInt64 = 1_000_000_000 private var key: SymmetricKey? private var nonce: UInt64 = 0 private var useExtractedNonce: Bool = false // Sliding window replay protection (only used when useExtractedNonce = true) private var highestReceivedNonce: UInt64 = 0 private var replayWindow: [UInt8] = Array(repeating: 0, count: REPLAY_WINDOW_BYTES) init() {} init(key: SymmetricKey, useExtractedNonce: Bool = false) { self.key = key self.useExtractedNonce = useExtractedNonce } deinit { clearSensitiveData() } func initializeKey(_ key: SymmetricKey) { self.key = key self.nonce = 0 } func hasKey() -> Bool { return key != nil } // MARK: - Sliding Window Replay Protection /// Check if nonce is valid for replay protection /// BCH-01-010: Use safe arithmetic to prevent integer overflow private func isValidNonce(_ receivedNonce: UInt64) -> Bool { // Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest) // use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE) let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE) if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize { return false // Too old, outside window } if receivedNonce > highestReceivedNonce { return true // Always accept newer nonces } let offset = Int(highestReceivedNonce - receivedNonce) let byteIndex = offset / 8 let bitIndex = offset % 8 return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen } /// Mark nonce as seen in replay window private func markNonceAsSeen(_ receivedNonce: UInt64) { if receivedNonce > highestReceivedNonce { let shift = Int(receivedNonce - highestReceivedNonce) if shift >= Self.REPLAY_WINDOW_SIZE { // Clear entire window - shift is too large replayWindow = Array(repeating: 0, count: Self.REPLAY_WINDOW_BYTES) } else { // Shift window right by `shift` bits for i in stride(from: Self.REPLAY_WINDOW_BYTES - 1, through: 0, by: -1) { let sourceByteIndex = i - shift / 8 var newByte: UInt8 = 0 if sourceByteIndex >= 0 { newByte = replayWindow[sourceByteIndex] >> (shift % 8) if sourceByteIndex > 0 && shift % 8 != 0 { newByte |= replayWindow[sourceByteIndex - 1] << (8 - shift % 8) } } replayWindow[i] = newByte } } highestReceivedNonce = receivedNonce replayWindow[0] |= 1 // Mark most recent bit as seen } else { let offset = Int(highestReceivedNonce - receivedNonce) let byteIndex = offset / 8 let bitIndex = offset % 8 replayWindow[byteIndex] |= (1 << bitIndex) } } /// Extract nonce from combined payload /// Returns tuple of (nonce, ciphertext) or nil if invalid private func extractNonceFromCiphertextPayload(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? { guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else { return nil } // Extract 4-byte nonce (big-endian) let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES) let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in let byteArray = bytes.bindMemory(to: UInt8.self) var result: UInt64 = 0 for i in 0.. Data { var bytes = Data(count: Self.NONCE_SIZE_BYTES) withUnsafeBytes(of: nonce.bigEndian) { ptr in // Copy only the last 4 bytes from the 8-byte UInt64 let sourceBytes = ptr.bindMemory(to: UInt8.self) bytes.replaceSubrange(0.. Data { guard let key = self.key else { throw NoiseError.uninitializedCipher } // Debug logging for nonce tracking let currentNonce = nonce // Check if nonce exceeds 4-byte limit (UInt32 max value) guard nonce <= UInt64(UInt32.max) - 1 else { throw NoiseError.nonceExceeded } // Create nonce from counter var nonceData = Data(count: 12) withUnsafeBytes(of: currentNonce.littleEndian) { bytes in nonceData.replaceSubrange(4..<12, with: bytes) } let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData) // increment local nonce nonce += 1 // Create combined payload: let combinedPayload: Data if (useExtractedNonce) { let nonceBytes = nonceToBytes(currentNonce) combinedPayload = nonceBytes + sealedBox.ciphertext + sealedBox.tag } else { combinedPayload = sealedBox.ciphertext + sealedBox.tag } // Log high nonce values that might indicate issues if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption) } return combinedPayload } func decrypt(ciphertext: Data, associatedData: Data = Data()) throws -> Data { guard let key = self.key else { throw NoiseError.uninitializedCipher } guard ciphertext.count >= 16 else { throw NoiseError.invalidCiphertext } let encryptedData: Data let tag: Data let decryptionNonce: UInt64 if useExtractedNonce { // Extract nonce and ciphertext from combined payload guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else { SecureLogger.debug("Decrypt failed: Could not extract nonce from payload") throw NoiseError.invalidCiphertext } // Validate nonce with sliding window replay protection guard isValidNonce(extractedNonce) else { SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected") throw NoiseError.replayDetected } // Split ciphertext and tag encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16) tag = actualCiphertext.suffix(16) decryptionNonce = extractedNonce } else { // Split ciphertext and tag encryptedData = ciphertext.prefix(ciphertext.count - 16) tag = ciphertext.suffix(16) decryptionNonce = nonce } // Create nonce from counter var nonceData = Data(count: 12) withUnsafeBytes(of: decryptionNonce.littleEndian) { bytes in nonceData.replaceSubrange(4..<12, with: bytes) } let sealedBox = try ChaChaPoly.SealedBox( nonce: ChaChaPoly.Nonce(data: nonceData), ciphertext: encryptedData, tag: tag ) // Log high nonce values that might indicate issues if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { SecureLogger.warning("High nonce value detected: \(decryptionNonce) - consider rekeying", category: .encryption) } do { let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData) // BCH-01-010: Atomic nonce state update // Both replay window marking and nonce increment must complete together // to prevent state desynchronization. We perform both after successful // decryption only, ensuring state consistency on any failure path. if useExtractedNonce { markNonceAsSeen(decryptionNonce) } nonce += 1 return plaintext } catch { // Decryption failed - nonce state remains unchanged (atomic rollback) SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)") SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption) throw error } } /// Securely clear sensitive cryptographic data from memory func clearSensitiveData() { // Clear the symmetric key key = nil // Reset nonce nonce = 0 highestReceivedNonce = 0 // Clear replay window for i in 0.. (nonce: UInt64, ciphertext: Data)? { try extractNonceFromCiphertextPayload(combinedPayload) } #endif } // MARK: - Symmetric State /// Manages the symmetric cryptographic state during Noise handshakes. /// Responsible for key derivation, protocol name hashing, and maintaining /// the chaining key that provides key separation between handshake messages. /// - Note: This class implements the SymmetricState object from the Noise spec final class NoiseSymmetricState { private var cipherState: NoiseCipherState private var chainingKey: Data private var hash: Data init(protocolName: String) { self.cipherState = NoiseCipherState() // Initialize with protocol name let nameData = protocolName.data(using: .utf8)! if nameData.count <= 32 { self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count) } else { self.hash = nameData.sha256Hash() } self.chainingKey = self.hash } func mixKey(_ inputKeyMaterial: Data) { let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: inputKeyMaterial, numOutputs: 2) chainingKey = output[0] let tempKey = SymmetricKey(data: output[1]) cipherState.initializeKey(tempKey) } func mixHash(_ data: Data) { hash = (hash + data).sha256Hash() } func mixKeyAndHash(_ inputKeyMaterial: Data) { let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: inputKeyMaterial, numOutputs: 3) chainingKey = output[0] mixHash(output[1]) let tempKey = SymmetricKey(data: output[2]) cipherState.initializeKey(tempKey) } func getHandshakeHash() -> Data { return hash } func hasCipherKey() -> Bool { return cipherState.hasKey() } func encryptAndHash(_ plaintext: Data) throws -> Data { if cipherState.hasKey() { let ciphertext = try cipherState.encrypt(plaintext: plaintext, associatedData: hash) mixHash(ciphertext) return ciphertext } else { mixHash(plaintext) return plaintext } } func decryptAndHash(_ ciphertext: Data) throws -> Data { if cipherState.hasKey() { let plaintext = try cipherState.decrypt(ciphertext: ciphertext, associatedData: hash) mixHash(ciphertext) return plaintext } else { mixHash(ciphertext) return ciphertext } } func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) { let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2) let tempKey1 = SymmetricKey(data: output[0]) let tempKey2 = SymmetricKey(data: output[1]) let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce) let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce) // BCH-01-010: Clear symmetric state after split per Noise spec // The chaining key and hash should not be retained after handshake completes clearSensitiveData() return (c1, c2) } /// BCH-01-010: Securely clear sensitive cryptographic state /// Called after split() to clear chaining key and hash per Noise spec func clearSensitiveData() { // Clear chaining key by overwriting with zeros let chainingKeyCount = chainingKey.count chainingKey = Data(repeating: 0, count: chainingKeyCount) // Clear hash by overwriting with zeros let hashCount = hash.count hash = Data(repeating: 0, count: hashCount) // Clear the internal cipher state cipherState.clearSensitiveData() } deinit { clearSensitiveData() } // HKDF implementation private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] { let tempKey = HMAC.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey)) let tempKeyData = Data(tempKey) var outputs: [Data] = [] var currentOutput = Data() for i in 1...numOutputs { currentOutput = Data(HMAC.authenticationCode( for: currentOutput + Data([UInt8(i)]), using: SymmetricKey(data: tempKeyData) )) outputs.append(currentOutput) } return outputs } } // MARK: - Handshake State /// Orchestrates the complete Noise handshake process. /// This is the main interface for establishing encrypted sessions between peers. /// Manages the handshake state machine, message patterns, and key derivation. /// - Important: Each handshake instance should only be used once final class NoiseHandshakeState { private let role: NoiseRole private let pattern: NoisePattern private let keychain: KeychainManagerProtocol private var symmetricState: NoiseSymmetricState // Keys private var localStaticPrivate: Curve25519.KeyAgreement.PrivateKey? private var localStaticPublic: Curve25519.KeyAgreement.PublicKey? private var localEphemeralPrivate: Curve25519.KeyAgreement.PrivateKey? private var localEphemeralPublic: Curve25519.KeyAgreement.PublicKey? private var remoteStaticPublic: Curve25519.KeyAgreement.PublicKey? private var remoteEphemeralPublic: Curve25519.KeyAgreement.PublicKey? // Message patterns private var messagePatterns: [[NoiseMessagePattern]] = [] private var currentPattern = 0 // Test support: predetermined ephemeral keys for test vectors private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? private var prologueData: Data init( role: NoiseRole, pattern: NoisePattern, keychain: KeychainManagerProtocol, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil, prologue: Data = Data(), predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil ) { self.role = role self.pattern = pattern self.keychain = keychain self.prologueData = prologue self.predeterminedEphemeralKey = predeterminedEphemeralKey // Initialize static keys if let localKey = localStaticKey { self.localStaticPrivate = localKey self.localStaticPublic = localKey.publicKey } self.remoteStaticPublic = remoteStaticKey // Initialize protocol name let protocolName = NoiseProtocolName(pattern: pattern.patternName) self.symmetricState = NoiseSymmetricState(protocolName: protocolName.fullName) // Initialize message patterns self.messagePatterns = pattern.messagePatterns // Mix pre-message keys according to pattern mixPreMessageKeys() } private func mixPreMessageKeys() { // Mix prologue symmetricState.mixHash(self.prologueData) // For XX pattern, no pre-message keys // For IK/NK patterns, we'd mix the responder's static key here switch pattern { case .XX: break // No pre-message keys case .IK, .NK: if role == .initiator, let remoteStatic = remoteStaticPublic { symmetricState.mixHash(remoteStatic.rawRepresentation) } else if role == .responder, let localStatic = localStaticPublic { symmetricState.mixHash(localStatic.rawRepresentation) } } } func writeMessage(payload: Data = Data()) throws -> Data { guard currentPattern < messagePatterns.count else { throw NoiseError.handshakeComplete } var messageBuffer = Data() let patterns = messagePatterns[currentPattern] for pattern in patterns { switch pattern { case .e: // Generate ephemeral key (or use predetermined key for tests) if let predetermined = predeterminedEphemeralKey { localEphemeralPrivate = predetermined predeterminedEphemeralKey = nil } else { localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey() } localEphemeralPublic = localEphemeralPrivate!.publicKey messageBuffer.append(localEphemeralPublic!.rawRepresentation) symmetricState.mixHash(localEphemeralPublic!.rawRepresentation) case .s: // Send static key (encrypted if cipher is initialized) guard let staticPublic = localStaticPublic else { throw NoiseError.missingLocalStaticKey } let encrypted = try symmetricState.encryptAndHash(staticPublic.rawRepresentation) messageBuffer.append(encrypted) case .ee: // DH(local ephemeral, remote ephemeral) guard let localEphemeral = localEphemeralPrivate, let remoteEphemeral = remoteEphemeralPublic else { throw NoiseError.missingKeys } let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral) var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) case .es: // DH(ephemeral, static) - direction depends on role if role == .initiator { guard let localEphemeral = localEphemeralPrivate, let remoteStatic = remoteStaticPublic else { throw NoiseError.missingKeys } let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic) var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) } else { guard let localStatic = localStaticPrivate, let remoteEphemeral = remoteEphemeralPublic else { throw NoiseError.missingKeys } let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral) var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) } case .se: // DH(static, ephemeral) - direction depends on role if role == .initiator { guard let localStatic = localStaticPrivate, let remoteEphemeral = remoteEphemeralPublic else { throw NoiseError.missingKeys } let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral) var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) } else { guard let localEphemeral = localEphemeralPrivate, let remoteStatic = remoteStaticPublic else { throw NoiseError.missingKeys } let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic) var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) } case .ss: // DH(static, static) guard let localStatic = localStaticPrivate, let remoteStatic = remoteStaticPublic else { throw NoiseError.missingKeys } let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic) var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) } } // Encrypt payload let encryptedPayload = try symmetricState.encryptAndHash(payload) messageBuffer.append(encryptedPayload) currentPattern += 1 return messageBuffer } func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data { guard currentPattern < messagePatterns.count else { throw NoiseError.handshakeComplete } var buffer = message let patterns = messagePatterns[currentPattern] for pattern in patterns { switch pattern { case .e: // Read ephemeral key guard buffer.count >= 32 else { throw NoiseError.invalidMessage } let ephemeralData = buffer.prefix(32) buffer = buffer.dropFirst(32) do { remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData) } catch { SecureLogger.warning("Invalid ephemeral public key received", category: .security) throw NoiseError.invalidMessage } symmetricState.mixHash(ephemeralData) case .s: // Read static key (may be encrypted) let keyLength = symmetricState.hasCipherKey() ? 48 : 32 // 32 + 16 byte tag if encrypted guard buffer.count >= keyLength else { throw NoiseError.invalidMessage } let staticData = buffer.prefix(keyLength) buffer = buffer.dropFirst(keyLength) do { let decrypted = try symmetricState.decryptAndHash(staticData) remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted) } catch { SecureLogger.error(.authenticationFailed(peerID: "Unknown - handshake")) throw NoiseError.authenticationFailure } case .ee, .es, .se, .ss: // Same DH operations as in writeMessage try performDHOperation(pattern) } } // Decrypt payload let payload = try symmetricState.decryptAndHash(buffer) currentPattern += 1 return payload } private func performDHOperation(_ pattern: NoiseMessagePattern) throws { switch pattern { case .ee: guard let localEphemeral = localEphemeralPrivate, let remoteEphemeral = remoteEphemeralPublic else { throw NoiseError.missingKeys } let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral) var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) case .es: if role == .initiator { guard let localEphemeral = localEphemeralPrivate, let remoteStatic = remoteStaticPublic else { throw NoiseError.missingKeys } let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic) var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) } else { guard let localStatic = localStaticPrivate, let remoteEphemeral = remoteEphemeralPublic else { throw NoiseError.missingKeys } let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral) var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) } case .se: if role == .initiator { guard let localStatic = localStaticPrivate, let remoteEphemeral = remoteEphemeralPublic else { throw NoiseError.missingKeys } let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral) var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) } else { guard let localEphemeral = localEphemeralPrivate, let remoteStatic = remoteStaticPublic else { throw NoiseError.missingKeys } let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic) var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) } case .ss: guard let localStatic = localStaticPrivate, let remoteStatic = remoteStaticPublic else { throw NoiseError.missingKeys } let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic) var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) case .e, .s: break } } func isHandshakeComplete() -> Bool { return currentPattern >= messagePatterns.count } func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) { guard isHandshakeComplete() else { throw NoiseError.handshakeNotComplete } // BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state let finalHandshakeHash = symmetricState.getHandshakeHash() let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce) // Initiator uses c1 for sending, c2 for receiving // Responder uses c2 for sending, c1 for receiving let ciphers = role == .initiator ? (c1, c2) : (c2, c1) return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash) } func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? { return remoteStaticPublic } func getHandshakeHash() -> Data { return symmetricState.getHandshakeHash() } #if DEBUG func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws { try performDHOperation(pattern) } func setCurrentPatternForTesting(_ currentPattern: Int) { self.currentPattern = currentPattern } func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) { self.remoteEphemeralPublic = key } #endif } // MARK: - Pattern Extensions extension NoisePattern { var patternName: String { switch self { case .XX: return "XX" case .IK: return "IK" case .NK: return "NK" } } var messagePatterns: [[NoiseMessagePattern]] { switch self { case .XX: return [ [.e], // -> e [.e, .ee, .s, .es], // <- e, ee, s, es [.s, .se] // -> s, se ] case .IK: return [ [.e, .es, .s, .ss], // -> e, es, s, ss [.e, .ee, .se] // <- e, ee, se ] case .NK: return [ [.e, .es], // -> e, es [.e, .ee] // <- e, ee ] } } } // MARK: - Errors enum NoiseError: Error { case uninitializedCipher case invalidCiphertext case handshakeComplete case handshakeNotComplete case missingLocalStaticKey case missingKeys case invalidMessage case authenticationFailure case invalidPublicKey case replayDetected case nonceExceeded } // MARK: - Constant-Time Operations /// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks /// This function compares two Data objects in constant time, preventing /// information leakage via timing analysis. private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool { guard a.count == b.count else { return false } var result: UInt8 = 0 for i in 0.. Bool { var result: UInt8 = 0 for byte in data { result |= byte } return result == 0 } // MARK: - Key Validation extension NoiseHandshakeState { /// Validate a Curve25519 public key /// Checks for weak/invalid keys that could compromise security /// BCH-01-010: Uses constant-time operations to prevent timing side-channels static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey { // Check key length guard keyData.count == 32 else { throw NoiseError.invalidPublicKey } // BCH-01-010: Constant-time check for all-zero key (point at infinity) if constantTimeIsZero(keyData) { throw NoiseError.invalidPublicKey } // Check for low-order points that could enable small subgroup attacks // These are the known bad points for Curve25519 let lowOrderPoints: [Data] = [ Data(repeating: 0x00, count: 32), // Already checked above Data([0x01] + Data(repeating: 0x00, count: 31)), // Point of order 1 Data([0x00] + Data(repeating: 0x00, count: 30) + [0x01]), // Another low-order point Data([0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f, 0xc4, 0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49, 0xb8, 0x00]), // Low order point Data([0x5f, 0x9c, 0x95, 0xbc, 0xa3, 0x50, 0x8c, 0x24, 0xb1, 0xd0, 0xb1, 0x55, 0x9c, 0x83, 0xef, 0x5b, 0x04, 0x44, 0x5c, 0xc4, 0x58, 0x1c, 0x8e, 0x86, 0xd8, 0x22, 0x4e, 0xdd, 0xd0, 0x9f, 0x11, 0x57]), // Low order point Data(repeating: 0xFF, count: 32), // All ones Data([0xda, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), // Another bad point Data([0xdb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point ] // BCH-01-010: Constant-time check against known bad points // We check all points and accumulate matches to avoid early exit timing leaks var foundBadPoint = false for badPoint in lowOrderPoints { if constantTimeCompare(keyData, badPoint) { foundBadPoint = true } } if foundBadPoint { SecureLogger.warning("Low-order point detected", category: .security) throw NoiseError.invalidPublicKey } // Try to create the key - CryptoKit will validate curve points internally do { let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData) return publicKey } catch { // If CryptoKit rejects it, it's invalid SecureLogger.warning("CryptoKit validation failed", category: .security) throw NoiseError.invalidPublicKey } } } ================================================ FILE: bitchat/Noise/NoiseRateLimiter.swift ================================================ // // NoiseRateLimiter.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import BitLogger import Foundation final class NoiseRateLimiter { private var handshakeTimestamps: [PeerID: [Date]] = [:] private var messageTimestamps: [PeerID: [Date]] = [:] // Global rate limiting private var globalHandshakeTimestamps: [Date] = [] private var globalMessageTimestamps: [Date] = [] private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent) func allowHandshake(from peerID: PeerID) -> Bool { return queue.sync(flags: .barrier) { let now = Date() let oneMinuteAgo = now.addingTimeInterval(-60) // Check global rate limit first globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo } if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute { SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security) return false } // Check per-peer rate limit var timestamps = handshakeTimestamps[peerID] ?? [] timestamps = timestamps.filter { $0 > oneMinuteAgo } if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute { SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security) return false } // Record new handshake timestamps.append(now) handshakeTimestamps[peerID] = timestamps globalHandshakeTimestamps.append(now) return true } } func allowMessage(from peerID: PeerID) -> Bool { return queue.sync(flags: .barrier) { let now = Date() let oneSecondAgo = now.addingTimeInterval(-1) // Check global rate limit first globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo } if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond { SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security) return false } // Check per-peer rate limit var timestamps = messageTimestamps[peerID] ?? [] timestamps = timestamps.filter { $0 > oneSecondAgo } if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond { SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security) return false } // Record new message timestamps.append(now) messageTimestamps[peerID] = timestamps globalMessageTimestamps.append(now) return true } } func reset(for peerID: PeerID) { queue.async(flags: .barrier) { self.handshakeTimestamps.removeValue(forKey: peerID) self.messageTimestamps.removeValue(forKey: peerID) } } func resetAll() { queue.async(flags: .barrier) { self.handshakeTimestamps.removeAll() self.messageTimestamps.removeAll() self.globalHandshakeTimestamps.removeAll() self.globalMessageTimestamps.removeAll() } } } ================================================ FILE: bitchat/Noise/NoiseSecurityConstants.swift ================================================ // // NoiseSecurityConstants.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation enum NoiseSecurityConstants { // Maximum message size to prevent memory exhaustion static let maxMessageSize = 65535 // 64KB as per Noise spec // Maximum handshake message size static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern // Session timeout - sessions older than this should be renegotiated static let sessionTimeout: TimeInterval = 86400 // 24 hours // Maximum number of messages before rekey (2^64 - 1 is the nonce limit) static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages // Handshake timeout - abandon incomplete handshakes static let handshakeTimeout: TimeInterval = 60 // 1 minute // Maximum concurrent sessions per peer static let maxSessionsPerPeer = 3 // Rate limiting static let maxHandshakesPerMinute = 10 static let maxMessagesPerSecond = 100 // Global rate limiting (across all peers) static let maxGlobalHandshakesPerMinute = 30 static let maxGlobalMessagesPerSecond = 500 } ================================================ FILE: bitchat/Noise/NoiseSecurityError.swift ================================================ // // NoiseSecurityError.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation enum NoiseSecurityError: Error { case sessionExpired case sessionExhausted case messageTooLarge case invalidPeerID case rateLimitExceeded case handshakeTimeout } ================================================ FILE: bitchat/Noise/NoiseSecurityValidator.swift ================================================ // // NoiseSecurityValidator.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation struct NoiseSecurityValidator { /// Validate message size static func validateMessageSize(_ data: Data) -> Bool { return data.count <= NoiseSecurityConstants.maxMessageSize } /// Validate handshake message size static func validateHandshakeMessageSize(_ data: Data) -> Bool { return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize } } ================================================ FILE: bitchat/Noise/NoiseSession.swift ================================================ // // NoiseSession.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import BitLogger import Foundation import CryptoKit class NoiseSession { let peerID: PeerID let role: NoiseRole private let keychain: KeychainManagerProtocol private var state: NoiseSessionState = .uninitialized private var handshakeState: NoiseHandshakeState? private var sendCipher: NoiseCipherState? private var receiveCipher: NoiseCipherState? // Keys private let localStaticKey: Curve25519.KeyAgreement.PrivateKey private var remoteStaticPublicKey: Curve25519.KeyAgreement.PublicKey? // Handshake messages for retransmission private var sentHandshakeMessages: [Data] = [] private var handshakeHash: Data? // Thread safety private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent) init( peerID: PeerID, role: NoiseRole, keychain: KeychainManagerProtocol, localStaticKey: Curve25519.KeyAgreement.PrivateKey, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil ) { self.peerID = peerID self.role = role self.keychain = keychain self.localStaticKey = localStaticKey self.remoteStaticPublicKey = remoteStaticKey } // MARK: - Handshake func startHandshake() throws -> Data { return try sessionQueue.sync(flags: .barrier) { guard case .uninitialized = state else { throw NoiseSessionError.invalidState } // For XX pattern, we don't need remote static key upfront handshakeState = NoiseHandshakeState( role: role, pattern: .XX, keychain: keychain, localStaticKey: localStaticKey, remoteStaticKey: nil ) state = .handshaking // Only initiator writes the first message if role == .initiator { let message = try handshakeState!.writeMessage() sentHandshakeMessages.append(message) return message } else { // Responder doesn't send first message in XX pattern return Data() } } } func processHandshakeMessage(_ message: Data) throws -> Data? { return try sessionQueue.sync(flags: .barrier) { SecureLogger.debug("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)") // Initialize handshake state if needed (for responders) if state == .uninitialized && role == .responder { handshakeState = NoiseHandshakeState( role: role, pattern: .XX, keychain: keychain, localStaticKey: localStaticKey, remoteStaticKey: nil ) state = .handshaking SecureLogger.debug("NoiseSession[\(peerID)]: Initialized handshake state for responder") } guard case .handshaking = state, let handshake = handshakeState else { throw NoiseSessionError.invalidState } // Process incoming message _ = try handshake.readMessage(message) SecureLogger.debug("NoiseSession[\(peerID)]: Read handshake message, checking if complete") // Check if handshake is complete if handshake.isHandshakeComplete() { // Get transport ciphers and handshake hash (hash captured before split clears state) let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true) sendCipher = send receiveCipher = receive // Store remote static key remoteStaticPublicKey = handshake.getRemoteStaticPublicKey() // Store handshake hash for channel binding handshakeHash = hash state = .established handshakeState = nil // Clear handshake state SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established") SecureLogger.info(.handshakeCompleted(peerID: peerID.id)) return nil } else { // Generate response let response = try handshake.writeMessage() sentHandshakeMessages.append(response) SecureLogger.debug("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)") // Check if handshake is complete after writing if handshake.isHandshakeComplete() { // Get transport ciphers and handshake hash (hash captured before split clears state) let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true) sendCipher = send receiveCipher = receive // Store remote static key remoteStaticPublicKey = handshake.getRemoteStaticPublicKey() // Store handshake hash for channel binding handshakeHash = hash state = .established handshakeState = nil // Clear handshake state SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established") SecureLogger.info(.handshakeCompleted(peerID: peerID.id)) } return response } } } // MARK: - Transport func encrypt(_ plaintext: Data) throws -> Data { return try sessionQueue.sync(flags: .barrier) { guard case .established = state, let cipher = sendCipher else { throw NoiseSessionError.notEstablished } return try cipher.encrypt(plaintext: plaintext) } } func decrypt(_ ciphertext: Data) throws -> Data { return try sessionQueue.sync(flags: .barrier) { guard case .established = state, let cipher = receiveCipher else { throw NoiseSessionError.notEstablished } return try cipher.decrypt(ciphertext: ciphertext) } } // MARK: - State Management func getState() -> NoiseSessionState { return sessionQueue.sync { return state } } func isEstablished() -> Bool { return sessionQueue.sync { if case .established = state { return true } return false } } func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? { return sessionQueue.sync { return remoteStaticPublicKey } } func reset() { sessionQueue.sync(flags: .barrier) { let wasEstablished = state == .established state = .uninitialized handshakeState = nil // Clear sensitive cipher states sendCipher?.clearSensitiveData() receiveCipher?.clearSensitiveData() sendCipher = nil receiveCipher = nil // Clear sent handshake messages for i in 0.. // enum NoiseSessionError: Error, Equatable { case invalidState case notEstablished case sessionNotFound case alreadyEstablished } ================================================ FILE: bitchat/Noise/NoiseSessionManager.swift ================================================ // // NoiseSessionManager.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import BitLogger import CryptoKit import Foundation final class NoiseSessionManager { private var sessions: [PeerID: NoiseSession] = [:] private let localStaticKey: Curve25519.KeyAgreement.PrivateKey private let keychain: KeychainManagerProtocol private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) // Callbacks var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)? var onSessionFailed: ((PeerID, Error) -> Void)? init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { self.localStaticKey = localStaticKey self.keychain = keychain self.sessionFactory = { peerID, role in SecureNoiseSession( peerID: peerID, role: role, keychain: keychain, localStaticKey: localStaticKey ) } } #if DEBUG init( localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol, sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession ) { self.localStaticKey = localStaticKey self.keychain = keychain self.sessionFactory = sessionFactory } #endif // MARK: - Session Management func getSession(for peerID: PeerID) -> NoiseSession? { return managerQueue.sync { return sessions[peerID] } } func removeSession(for peerID: PeerID) { managerQueue.sync(flags: .barrier) { if let session = sessions.removeValue(forKey: peerID) { session.reset() // Clear sensitive data before removing } } } func removeAllSessions() { managerQueue.sync(flags: .barrier) { for (_, session) in sessions { session.reset() } sessions.removeAll() } } // MARK: - Handshake Helpers func initiateHandshake(with peerID: PeerID) throws -> Data { return try managerQueue.sync(flags: .barrier) { // Check if we already have an established session if let existingSession = sessions[peerID], existingSession.isEstablished() { // Session already established, don't recreate throw NoiseSessionError.alreadyEstablished } // Remove any existing non-established session if let existingSession = sessions[peerID], !existingSession.isEstablished() { _ = sessions.removeValue(forKey: peerID) } // Create new initiator session let session = sessionFactory(peerID, .initiator) sessions[peerID] = session do { let handshakeData = try session.startHandshake() return handshakeData } catch { // Clean up failed session _ = sessions.removeValue(forKey: peerID) SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) throw error } } } func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { // Process everything within the synchronized block to prevent race conditions return try managerQueue.sync(flags: .barrier) { var shouldCreateNew = false var existingSession: NoiseSession? = nil if let existing = sessions[peerID] { // If we have an established session, the peer must have cleared their session // for a good reason (e.g., decryption failure, restart, etc.) // We should accept the new handshake to re-establish encryption if existing.isEstablished() { SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session) _ = sessions.removeValue(forKey: peerID) shouldCreateNew = true } else { // If we're in the middle of a handshake and receive a new initiation, // reset and start fresh (the other side may have restarted) if existing.getState() == .handshaking && message.count == 32 { _ = sessions.removeValue(forKey: peerID) shouldCreateNew = true } else { existingSession = existing } } } else { shouldCreateNew = true } // Get or create session let session: NoiseSession if shouldCreateNew { let newSession = sessionFactory(peerID, .responder) sessions[peerID] = newSession session = newSession } else { session = existingSession! } // Process the handshake message within the synchronized block do { let response = try session.processHandshakeMessage(message) // Check if session is established after processing if session.isEstablished() { if let remoteKey = session.getRemoteStaticPublicKey() { // Schedule callback outside the synchronized block to prevent deadlock DispatchQueue.global().async { [weak self] in self?.onSessionEstablished?(peerID, remoteKey) } } } return response } catch { // Reset the session on handshake failure so next attempt can start fresh _ = sessions.removeValue(forKey: peerID) // Schedule callback outside the synchronized block to prevent deadlock DispatchQueue.global().async { [weak self] in self?.onSessionFailed?(peerID, error) } SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) throw error } } } // MARK: - Encryption/Decryption func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data { guard let session = getSession(for: peerID) else { throw NoiseSessionError.sessionNotFound } return try session.encrypt(plaintext) } func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data { guard let session = getSession(for: peerID) else { throw NoiseSessionError.sessionNotFound } return try session.decrypt(ciphertext) } // MARK: - Key Management func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? { return getSession(for: peerID)?.getRemoteStaticPublicKey() } // MARK: - Session Rekeying func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] { return managerQueue.sync { var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = [] for (peerID, session) in sessions { if let secureSession = session as? SecureNoiseSession, secureSession.isEstablished(), secureSession.needsRenegotiation() { needingRekey.append((peerID: peerID, needsRekey: true)) } } return needingRekey } } func initiateRekey(for peerID: PeerID) throws { // Remove old session removeSession(for: peerID) // Initiate new handshake _ = try initiateHandshake(with: peerID) } } ================================================ FILE: bitchat/Noise/NoiseSessionState.swift ================================================ // // NoiseSessionState.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // enum NoiseSessionState: Equatable { case uninitialized case handshaking case established } ================================================ FILE: bitchat/Noise/SecureNoiseSession.swift ================================================ // // SecureNoiseSession.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation final class SecureNoiseSession: NoiseSession { private(set) var messageCount: UInt64 = 0 private var sessionStartTime = Date() private(set) var lastActivityTime = Date() override func encrypt(_ plaintext: Data) throws -> Data { // Check session age if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout { throw NoiseSecurityError.sessionExpired } // Check message count if messageCount >= NoiseSecurityConstants.maxMessagesPerSession { throw NoiseSecurityError.sessionExhausted } // Validate message size guard NoiseSecurityValidator.validateMessageSize(plaintext) else { throw NoiseSecurityError.messageTooLarge } let encrypted = try super.encrypt(plaintext) messageCount += 1 lastActivityTime = Date() return encrypted } override func decrypt(_ ciphertext: Data) throws -> Data { // Check session age if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout { throw NoiseSecurityError.sessionExpired } // Validate message size guard NoiseSecurityValidator.validateMessageSize(ciphertext) else { throw NoiseSecurityError.messageTooLarge } let decrypted = try super.decrypt(ciphertext) lastActivityTime = Date() return decrypted } func needsRenegotiation() -> Bool { // Check if we've used more than 90% of message limit let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9) if messageCount >= messageThreshold { return true } // Check if last activity was more than 30 minutes ago if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout { return true } return false } // MARK: - Testing Support #if DEBUG func setLastActivityTimeForTesting(_ date: Date) { lastActivityTime = date } func setMessageCountForTesting(_ count: UInt64) { messageCount = count } func setSessionStartTimeForTesting(_ date: Date) { sessionStartTime = date } #endif } ================================================ FILE: bitchat/Nostr/Bech32.swift ================================================ import Foundation /// Bech32 encoding for Nostr (minimal implementation) enum Bech32 { private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] static func encode(hrp: String, data: Data) throws -> String { let values = convertBits(from: 8, to: 5, pad: true, data: Array(data)) let checksum = createChecksum(hrp: hrp, values: values) let combined = values + checksum return hrp + "1" + combined.map { let index = charset.index(charset.startIndex, offsetBy: Int($0)) return String(charset[index]) }.joined() } static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) { // Find the last occurrence of '1' guard let separatorIndex = bech32String.lastIndex(of: "1") else { throw Bech32Error.invalidFormat } let hrp = String(bech32String[..= 6 else { throw Bech32Error.invalidChecksum } let payloadValues = Array(values.dropLast(6)) let checksum = Array(values.suffix(6)) let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues) guard checksum == expectedChecksum else { throw Bech32Error.invalidChecksum } // Convert back to bytes let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues) return (hrp: hrp, data: Data(bytes)) } enum Bech32Error: Error { case invalidFormat case invalidCharacter case invalidChecksum } private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] { var acc = 0 var bits = 0 var result = [UInt8]() let maxv = (1 << to) - 1 for value in data { acc = (acc << from) | Int(value) bits += from while bits >= to { bits -= to result.append(UInt8((acc >> bits) & maxv)) } } if pad && bits > 0 { result.append(UInt8((acc << (to - bits)) & maxv)) } return result } private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] { let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0] let polymod = polymod(checksumValues) ^ 1 var checksum = [UInt8]() for i in 0..<6 { checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31)) } return checksum } private static func hrpExpand(_ hrp: String) -> [UInt8] { var result = [UInt8]() for c in hrp { guard let asciiValue = c.asciiValue else { return [] // Return empty array for invalid input } result.append(UInt8(asciiValue >> 5)) } result.append(0) for c in hrp { guard let asciiValue = c.asciiValue else { return [] // Return empty array for invalid input } result.append(UInt8(asciiValue & 31)) } return result } private static func polymod(_ values: [UInt8]) -> Int { var chk = 1 for value in values { let b = chk >> 25 chk = (chk & 0x1ffffff) << 5 ^ Int(value) for i in 0..<5 { if (b >> i) & 1 == 1 { chk ^= generator[i] } } } return chk } } ================================================ FILE: bitchat/Nostr/GeoRelayDirectory.swift ================================================ import BitLogger import Foundation import Tor #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif /// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. struct GeoRelayDirectoryDependencies { var userDefaults: UserDefaults var notificationCenter: NotificationCenter var now: () -> Date var remoteURL: URL var fetchInterval: TimeInterval var refreshCheckInterval: TimeInterval var retryInitialSeconds: TimeInterval var retryMaxSeconds: TimeInterval var awaitTorReady: @Sendable () async -> Bool var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data) var readData: (URL) -> Data? var writeData: (Data, URL) throws -> Void var cacheURL: () -> URL? var bundledCSVURLs: () -> [URL] var currentDirectoryPath: () -> String? var retrySleep: (TimeInterval) async -> Void var activeNotificationName: Notification.Name? var autoStart: Bool } private extension GeoRelayDirectoryDependencies { @MainActor static func live() -> Self { #if os(iOS) let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification #elseif os(macOS) let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification #else let activeNotificationName: Notification.Name? = nil #endif return Self( userDefaults: .standard, notificationCenter: .default, now: Date.init, remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!, fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds, refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds, retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds, retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds, awaitTorReady: { await TorManager.shared.awaitReady() }, makeFetchData: { let session = TorURLSession.shared.session return { request in let (data, _) = try await session.data(for: request) return data } }, readData: { try? Data(contentsOf: $0) }, writeData: { data, url in try data.write(to: url, options: .atomic) }, cacheURL: { do { let base = try FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true ) let dir = base.appendingPathComponent("bitchat", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir.appendingPathComponent("georelays_cache.csv") } catch { return nil } }, bundledCSVURLs: { [ Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"), Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"), Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays") ].compactMap { $0 } }, currentDirectoryPath: { FileManager.default.currentDirectoryPath }, retrySleep: { delay in let nanoseconds = UInt64(delay * 1_000_000_000) try? await Task.sleep(nanoseconds: nanoseconds) }, activeNotificationName: activeNotificationName, autoStart: true ) } } @MainActor final class GeoRelayDirectory { private final class CleanupState { let notificationCenter: NotificationCenter var observers: [NSObjectProtocol] = [] var refreshTimer: Timer? var retryTask: Task? init(notificationCenter: NotificationCenter) { self.notificationCenter = notificationCenter } deinit { observers.forEach { notificationCenter.removeObserver($0) } refreshTimer?.invalidate() retryTask?.cancel() } } struct Entry: Hashable, Sendable { let host: String let lat: Double let lon: Double } private enum DetachedFetchOutcome: Sendable { case success(entries: [Entry], csv: String) case torNotReady case invalidData case network(String) } static let shared = GeoRelayDirectory() private(set) var entries: [Entry] = [] private let lastFetchKey = "georelay.lastFetchAt" private let dependencies: GeoRelayDirectoryDependencies private let cleanupState: CleanupState private var retryAttempt: Int = 0 private var isFetching: Bool = false private init() { self.dependencies = .live() self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter) entries = loadLocalEntries() if dependencies.autoStart { registerObservers() startRefreshTimer() prefetchIfNeeded() } } internal init(dependencies: GeoRelayDirectoryDependencies) { self.dependencies = dependencies self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter) entries = loadLocalEntries() if dependencies.autoStart { registerObservers() startRefreshTimer() prefetchIfNeeded() } } /// Returns up to `count` relay URLs (wss://) closest to the geohash center. func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] { let center = Geohash.decodeCenter(geohash) return closestRelays(toLat: center.lat, lon: center.lon, count: count) } /// Returns up to `count` relay URLs (wss://) closest to the given coordinate. func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] { guard !entries.isEmpty, count > 0 else { return [] } if entries.count <= count { return entries .sorted { a, b in haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon) } .map { "wss://\($0.host)" } } var best: [(entry: Entry, distance: Double)] = [] best.reserveCapacity(count) for entry in entries { let distance = haversineKm(lat, lon, entry.lat, entry.lon) if best.count < count { let idx = best.firstIndex { $0.distance > distance } ?? best.count best.insert((entry, distance), at: idx) } else if let worstDistance = best.last?.distance, distance < worstDistance { let idx = best.firstIndex { $0.distance > distance } ?? best.count best.insert((entry, distance), at: idx) best.removeLast() } } return best.map { "wss://\($0.entry.host)" } } // MARK: - Remote Fetch func prefetchIfNeeded(force: Bool = false) { guard !isFetching else { return } let now = dependencies.now() let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast if !force { guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return } } else if last != .distantPast, now.timeIntervalSince(last) < dependencies.retryInitialSeconds { // Skip forced fetches if we just refreshed moments ago. return } cancelRetry() fetchRemote() } private func fetchRemote() { guard !isFetching else { return } isFetching = true let request = URLRequest( url: dependencies.remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15 ) let awaitTorReady = dependencies.awaitTorReady let fetchData = dependencies.makeFetchData() Task { [weak self] in guard let self else { return } let outcome = await Self.fetchRemoteOutcome( request: request, awaitTorReady: awaitTorReady, fetchData: fetchData ) switch outcome { case .success(let parsed, let csv): self.handleFetchSuccess(entries: parsed, csv: csv) case .torNotReady: self.handleFetchFailure(.torNotReady) case .invalidData: self.handleFetchFailure(.invalidData) case .network(let description): self.handleFetchFailure(.network(description)) } } } nonisolated private static func fetchRemoteOutcome( request: URLRequest, awaitTorReady: @escaping @Sendable () async -> Bool, fetchData: @escaping @Sendable (URLRequest) async throws -> Data ) async -> DetachedFetchOutcome { await Task.detached(priority: .utility) { let ready = await awaitTorReady() guard ready else { return .torNotReady } do { let data = try await fetchData(request) guard let text = String(data: data, encoding: .utf8) else { return .invalidData } let parsed = Self.parseCSV(text) guard !parsed.isEmpty else { return .invalidData } return .success(entries: parsed, csv: text) } catch { return .network(error.localizedDescription) } }.value } private enum FetchFailure { case torNotReady case invalidData case network(String) } @MainActor private func handleFetchSuccess(entries parsed: [Entry], csv: String) { entries = parsed persistCache(csv) dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey) SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session) isFetching = false retryAttempt = 0 cancelRetry() } @MainActor private func handleFetchFailure(_ reason: FetchFailure) { switch reason { case .torNotReady: SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session) case .invalidData: SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session) case .network(let errorDescription): SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session) } isFetching = false scheduleRetry() } @MainActor private func scheduleRetry() { retryAttempt = min(retryAttempt + 1, 10) let base = dependencies.retryInitialSeconds let maxDelay = dependencies.retryMaxSeconds let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0))) let calculated = base * multiplier let delay = min(maxDelay, max(base, calculated)) cancelRetry() cleanupState.retryTask = Task { [weak self] in guard let self else { return } await self.dependencies.retrySleep(delay) guard !Task.isCancelled else { return } await MainActor.run { self.prefetchIfNeeded(force: true) } } } @MainActor private func cancelRetry() { cleanupState.retryTask?.cancel() cleanupState.retryTask = nil } private func persistCache(_ text: String) { guard let url = dependencies.cacheURL() else { return } guard let data = text.data(using: .utf8) else { return } do { try dependencies.writeData(data, url) } catch { SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session) } } // MARK: - Loading private func loadLocalEntries() -> [Entry] { // Prefer cached file if present if let cache = dependencies.cacheURL(), let data = dependencies.readData(cache), let text = String(data: data, encoding: .utf8) { let arr = Self.parseCSV(text) if !arr.isEmpty { return arr } } // Try bundled resource(s) let bundleCandidates = dependencies.bundledCSVURLs() for url in bundleCandidates { if let data = dependencies.readData(url), let text = String(data: data, encoding: .utf8) { let arr = Self.parseCSV(text) if !arr.isEmpty { return arr } } } // Try filesystem path (development/test) if let cwd = dependencies.currentDirectoryPath(), let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), let text = String(data: data, encoding: .utf8) { return Self.parseCSV(text) } SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session) return [] } nonisolated static func parseCSV(_ text: String) -> [Entry] { var result: Set = [] let lines = text.split(whereSeparator: { $0.isNewline }) for (idx, raw) in lines.enumerated() { let line = raw.trimmingCharacters(in: .whitespacesAndNewlines) if line.isEmpty { continue } if idx == 0 && line.lowercased().contains("relay url") { continue } let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } guard parts.count >= 3 else { continue } var host = parts[0] host = host.replacingOccurrences(of: "https://", with: "") host = host.replacingOccurrences(of: "http://", with: "") host = host.replacingOccurrences(of: "wss://", with: "") host = host.replacingOccurrences(of: "ws://", with: "") host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue } result.insert(Entry(host: host, lat: lat, lon: lon)) } return Array(result) } // MARK: - Observers & Timers private func registerObservers() { let center = dependencies.notificationCenter let torReady = center.addObserver( forName: .TorDidBecomeReady, object: nil, queue: .main ) { [weak self] _ in guard let self else { return } Task { @MainActor in self.prefetchIfNeeded(force: true) } } cleanupState.observers.append(torReady) if let activeNotificationName = dependencies.activeNotificationName { let didBecomeActive = center.addObserver( forName: activeNotificationName, object: nil, queue: .main ) { [weak self] _ in guard let self else { return } Task { @MainActor in self.prefetchIfNeeded() } } cleanupState.observers.append(didBecomeActive) } } private func startRefreshTimer() { cleanupState.refreshTimer?.invalidate() let interval = dependencies.refreshCheckInterval guard interval > 0 else { return } let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in guard let self else { return } Task { @MainActor in self.prefetchIfNeeded() } } cleanupState.refreshTimer = timer RunLoop.main.add(timer, forMode: .common) } var debugRetryAttempt: Int { retryAttempt } var debugHasRetryTask: Bool { cleanupState.retryTask != nil } var debugObserverCount: Int { cleanupState.observers.count } } // MARK: - Distance private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double { let r = 6371.0 // Earth radius in km let dLat = (lat2 - lat1) * .pi / 180 let dLon = (lon2 - lon1) * .pi / 180 let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2) let c = 2 * atan2(sqrt(a), sqrt(1 - a)) return r * c } ================================================ FILE: bitchat/Nostr/NostrEmbeddedBitChat.swift ================================================ import Foundation // MARK: - BitChat-over-Nostr Adapter struct NostrEmbeddedBitChat { /// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs. static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? { // TLV-encode the private message let pm = PrivateMessagePacket(messageID: messageID, content: content) guard let tlv = pm.encode() else { return nil } // Prefix with NoisePayloadType var payload = Data([NoisePayloadType.privateMessage.rawValue]) payload.append(tlv) // Determine 8-byte recipient ID to embed let recipientID = normalizeRecipientPeerID(recipientPeerID) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: Data(hexString: senderPeerID.id) ?? Data(), recipientID: Data(hexString: recipientID.id), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: 7 ) guard let data = packet.toBinaryData() else { return nil } return "bitchat1:" + base64URLEncode(data) } /// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs. static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? { guard type == .delivered || type == .readReceipt else { return nil } var payload = Data([type.rawValue]) payload.append(Data(messageID.utf8)) let recipientID = normalizeRecipientPeerID(recipientPeerID) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: Data(hexString: senderPeerID.id) ?? Data(), recipientID: Data(hexString: recipientID.id), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: 7 ) guard let data = packet.toBinaryData() else { return nil } return "bitchat1:" + base64URLEncode(data) } /// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs). static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? { guard type == .delivered || type == .readReceipt else { return nil } var payload = Data([type.rawValue]) payload.append(Data(messageID.utf8)) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: Data(hexString: senderPeerID.id) ?? Data(), recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: 7 ) guard let data = packet.toBinaryData() else { return nil } return "bitchat1:" + base64URLEncode(data) } /// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs). static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? { let pm = PrivateMessagePacket(messageID: messageID, content: content) guard let tlv = pm.encode() else { return nil } var payload = Data([NoisePayloadType.privateMessage.rawValue]) payload.append(tlv) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: Data(hexString: senderPeerID.id) ?? Data(), recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: 7 ) guard let data = packet.toBinaryData() else { return nil } return "bitchat1:" + base64URLEncode(data) } private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID { if let maybeData = Data(hexString: recipientPeerID.id) { if maybeData.count == 32 { // Treat as Noise static public key; derive peerID from fingerprint return PeerID(publicKey: maybeData) } else if maybeData.count == 8 { // Already an 8-byte peer ID return recipientPeerID } } // Fallback: return as-is (expecting 16 hex chars) – caller should pass a valid peer ID return recipientPeerID } /// Base64url encode without padding private static func base64URLEncode(_ data: Data) -> String { let b64 = data.base64EncodedString() return b64 .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } } ================================================ FILE: bitchat/Nostr/NostrIdentity.swift ================================================ import Foundation import P256K /// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging struct NostrIdentity: Codable { let privateKey: Data let publicKey: Data let npub: String // Bech32-encoded public key let createdAt: Date /// Memberwise initializer init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) { self.privateKey = privateKey self.publicKey = publicKey self.npub = npub self.createdAt = createdAt } /// Generate a new Nostr identity static func generate() throws -> NostrIdentity { // Generate Schnorr key for Nostr let schnorrKey = try P256K.Schnorr.PrivateKey() let xOnlyPubkey = Data(schnorrKey.xonly.bytes) let npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey) return NostrIdentity( privateKey: schnorrKey.dataRepresentation, publicKey: xOnlyPubkey, // Store x-only public key npub: npub, createdAt: Date() ) } /// Initialize from existing private key data init(privateKeyData: Data) throws { let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyData) let xOnlyPubkey = Data(schnorrKey.xonly.bytes) self.privateKey = privateKeyData self.publicKey = xOnlyPubkey self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey) self.createdAt = Date() } /// Get signing key for event signatures func signingKey() throws -> P256K.Signing.PrivateKey { try P256K.Signing.PrivateKey(dataRepresentation: privateKey) } /// Get Schnorr signing key for Nostr event signatures func schnorrSigningKey() throws -> P256K.Schnorr.PrivateKey { try P256K.Schnorr.PrivateKey(dataRepresentation: privateKey) } /// Get hex-encoded public key (for Nostr events) var publicKeyHex: String { // Public key is already stored as x-only (32 bytes) return publicKey.hexEncodedString() } } ================================================ FILE: bitchat/Nostr/NostrIdentityBridge.swift ================================================ import Foundation import CryptoKit /// Bridge between Noise and Nostr identities final class NostrIdentityBridge { private let keychainService = "chat.bitchat.nostr" private let currentIdentityKey = "nostr-current-identity" private let deviceSeedKey = "nostr-device-seed" // In-memory cache to avoid transient keychain access issues private var deviceSeedCache: Data? // Cache derived identities to avoid repeated crypto during view rendering private var derivedIdentityCache: [String: NostrIdentity] = [:] private let cacheLock = NSLock() private let keychain: KeychainManagerProtocol init(keychain: KeychainManagerProtocol = KeychainManager()) { self.keychain = keychain } /// Get or create the current Nostr identity func getCurrentNostrIdentity() throws -> NostrIdentity? { // Check if we already have a Nostr identity if let existingData = keychain.load(key: currentIdentityKey, service: keychainService), let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) { return identity } // Generate new Nostr identity let nostrIdentity = try NostrIdentity.generate() // Store it let data = try JSONEncoder().encode(nostrIdentity) keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil) return nostrIdentity } /// Associate a Nostr identity with a Noise public key (for favorites) func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) { let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" if let data = nostrPubkey.data(using: .utf8) { keychain.save(key: key, data: data, service: keychainService, accessible: nil) } } /// Get Nostr public key associated with a Noise public key func getNostrPublicKey(for noisePublicKey: Data) -> String? { let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" guard let data = keychain.load(key: key, service: keychainService), let pubkey = String(data: data, encoding: .utf8) else { return nil } return pubkey } /// Clear all Nostr identity associations and current identity func clearAllAssociations() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecMatchLimit as String: kSecMatchLimitAll, kSecReturnAttributes as String: true ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecSuccess, let items = result as? [[String: Any]] { for item in items { var deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService ] if let account = item[kSecAttrAccount as String] as? String { deleteQuery[kSecAttrAccount as String] = account } SecItemDelete(deleteQuery as CFDictionary) } } else if status == errSecItemNotFound { // nothing persisted; no action needed } deviceSeedCache = nil } // MARK: - Per-Geohash Identities (Location Channels) /// Returns a stable device seed used to derive unlinkable per-geohash identities. /// Stored only on device keychain. private func getOrCreateDeviceSeed() -> Data { if let cached = deviceSeedCache { return cached } if let existing = keychain.load(key: deviceSeedKey, service: keychainService) { // Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) deviceSeedCache = existing return existing } var seed = Data(count: 32) _ = seed.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) } // Ensure availability after first unlock to prevent unintended rotation when locked keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) deviceSeedCache = seed return seed } /// Derive a deterministic, unlinkable Nostr identity for a given geohash. /// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing /// if the candidate is not a valid secp256k1 private key. func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity { // Check cache first to avoid repeated crypto + keychain I/O during view rendering cacheLock.lock() if let cached = derivedIdentityCache[geohash] { cacheLock.unlock() return cached } cacheLock.unlock() let seed = getOrCreateDeviceSeed() guard let msg = geohash.data(using: .utf8) else { throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"]) } func candidateKey(iteration: UInt32) -> Data { var input = Data(msg) var iterBE = iteration.bigEndian withUnsafeBytes(of: &iterBE) { bytes in input.append(contentsOf: bytes) } let code = HMAC.authenticationCode(for: input, using: SymmetricKey(data: seed)) return Data(code) } // Try a few iterations to ensure a valid key can be formed for i in 0..<10 { let keyData = candidateKey(iteration: UInt32(i)) if let identity = try? NostrIdentity(privateKeyData: keyData) { // Cache the result cacheLock.lock() derivedIdentityCache[geohash] = identity cacheLock.unlock() return identity } } // As a final fallback, hash the seed+msg and try again let fallback = (seed + msg).sha256Hash() let identity = try NostrIdentity(privateKeyData: fallback) // Cache the result cacheLock.lock() derivedIdentityCache[geohash] = identity cacheLock.unlock() return identity } } ================================================ FILE: bitchat/Nostr/NostrProtocol.swift ================================================ import BitLogger import Foundation import CryptoKit import P256K import Security // Note: This file depends on Data extension from BinaryEncodingUtils.swift // Make sure BinaryEncodingUtils.swift is included in the target /// NIP-17 Protocol Implementation for Private Direct Messages struct NostrProtocol { /// Nostr event kinds enum EventKind: Int { case metadata = 0 case textNote = 1 case dm = 14 // NIP-17 DM rumor kind case seal = 13 // NIP-17 sealed event case giftWrap = 1059 // NIP-59 gift wrap case ephemeralEvent = 20000 case geohashPresence = 20001 } /// Create a NIP-17 private message static func createPrivateMessage( content: String, recipientPubkey: String, senderIdentity: NostrIdentity ) throws -> NostrEvent { // Creating private message // 1. Create the rumor (unsigned event) let rumor = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), kind: .dm, // NIP-17: DM rumor kind 14 tags: [], content: content ) // 2. Create ephemeral key for this message let ephemeralKey = try P256K.Schnorr.PrivateKey() // Created ephemeral key for seal // 3. Seal the rumor (encrypt to recipient) let sealedEvent = try createSeal( rumor: rumor, recipientPubkey: recipientPubkey, senderKey: ephemeralKey ) // 4. Gift wrap the sealed event (encrypt to recipient again) let giftWrap = try createGiftWrap( seal: sealedEvent, recipientPubkey: recipientPubkey, senderKey: ephemeralKey ) // Created gift wrap return giftWrap } /// 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, timestamp: Int) { // Starting decryption // 1. Unwrap the gift wrap let seal: NostrEvent do { seal = try unwrapGiftWrap( giftWrap: giftWrap, recipientKey: recipientIdentity.schnorrSigningKey() ) // Successfully unwrapped gift wrap } catch { SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session) throw error } // 2. Open the seal let rumor: NostrEvent do { rumor = try openSeal( seal: seal, recipientKey: recipientIdentity.schnorrSigningKey() ) // Successfully opened seal } catch { SecureLogger.error("❌ Failed to open seal: \(error)", category: .session) throw error } return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at) } /// Create a geohash-scoped ephemeral public message (kind 20000) static func createEphemeralGeohashEvent( content: String, geohash: String, senderIdentity: NostrIdentity, nickname: String? = nil, teleported: Bool = false ) throws -> NostrEvent { var tags = [["g", geohash]] if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty { tags.append(["n", nickname]) } if teleported { tags.append(["t", "teleport"]) } let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, tags: tags, content: content ) let schnorrKey = try senderIdentity.schnorrSigningKey() return try event.sign(with: schnorrKey) } /// Create a geohash presence heartbeat (kind 20001) /// Must contain empty content and NO nickname tag static func createGeohashPresenceEvent( geohash: String, senderIdentity: NostrIdentity ) throws -> NostrEvent { let tags = [["g", geohash]] let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), kind: .geohashPresence, tags: tags, content: "" ) let schnorrKey = try senderIdentity.schnorrSigningKey() return try event.sign(with: schnorrKey) } /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. static func createGeohashTextNote( content: String, geohash: String, senderIdentity: NostrIdentity, nickname: String? = nil ) throws -> NostrEvent { var tags = [["g", geohash]] if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty { tags.append(["n", nickname]) } let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), kind: .textNote, tags: tags, content: content ) let schnorrKey = try senderIdentity.schnorrSigningKey() return try event.sign(with: schnorrKey) } // MARK: - Private Methods private static func createSeal( rumor: NostrEvent, recipientPubkey: String, senderKey: P256K.Schnorr.PrivateKey ) throws -> NostrEvent { let rumorJSON = try rumor.jsonString() let encrypted = try encrypt( plaintext: rumorJSON, recipientPubkey: recipientPubkey, senderKey: senderKey ) let seal = NostrEvent( pubkey: Data(senderKey.xonly.bytes).hexEncodedString(), createdAt: randomizedTimestamp(), kind: .seal, tags: [], content: encrypted ) // Sign the seal with the sender's Schnorr private key return try seal.sign(with: senderKey) } private static func createGiftWrap( seal: NostrEvent, recipientPubkey: String, senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal ) throws -> NostrEvent { let sealJSON = try seal.jsonString() // Create new ephemeral key for gift wrap let wrapKey = try P256K.Schnorr.PrivateKey() // Creating gift wrap with ephemeral key // Encrypt the seal with the new ephemeral key (not the seal's key) let encrypted = try encrypt( plaintext: sealJSON, recipientPubkey: recipientPubkey, senderKey: wrapKey // Use the gift wrap ephemeral key ) let giftWrap = NostrEvent( pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(), createdAt: randomizedTimestamp(), kind: .giftWrap, tags: [["p", recipientPubkey]], // Tag recipient content: encrypted ) // Sign the gift wrap with the wrap Schnorr private key return try giftWrap.sign(with: wrapKey) } private static func unwrapGiftWrap( giftWrap: NostrEvent, recipientKey: P256K.Schnorr.PrivateKey ) throws -> NostrEvent { // Unwrapping gift wrap let decrypted = try decrypt( ciphertext: giftWrap.content, senderPubkey: giftWrap.pubkey, recipientKey: recipientKey ) guard let data = decrypted.data(using: .utf8), let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NostrError.invalidEvent } let seal = try NostrEvent(from: sealDict) // Unwrapped seal return seal } private static func openSeal( seal: NostrEvent, recipientKey: P256K.Schnorr.PrivateKey ) throws -> NostrEvent { let decrypted = try decrypt( ciphertext: seal.content, senderPubkey: seal.pubkey, recipientKey: recipientKey ) guard let data = decrypted.data(using: .utf8), let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NostrError.invalidEvent } return try NostrEvent(from: rumorDict) } // MARK: - Encryption (NIP-44 v2) private static func encrypt( plaintext: String, recipientPubkey: String, senderKey: P256K.Schnorr.PrivateKey ) throws -> String { guard let recipientPubkeyData = Data(hexString: recipientPubkey) else { throw NostrError.invalidPublicKey } // Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned) // Derive shared secret let sharedSecret = try deriveSharedSecret( privateKey: senderKey, publicKey: recipientPubkeyData ) // Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info) let key = try deriveNIP44V2Key(from: sharedSecret) // 24-byte random nonce for XChaCha20-Poly1305 var nonce24 = Data(count: 24) _ = nonce24.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) } let pt = Data(plaintext.utf8) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) // v2: base64url(nonce24 || ciphertext || tag) var combined = Data() combined.append(nonce24) combined.append(sealed.ciphertext) combined.append(sealed.tag) return "v2:" + base64URLEncode(combined) } private static func decrypt( ciphertext: String, senderPubkey: String, recipientKey: P256K.Schnorr.PrivateKey ) throws -> String { // Expect NIP-44 v2 format guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } let encoded = String(ciphertext.dropFirst(3)) guard let data = base64URLDecode(encoded), data.count > (24 + 16), let senderPubkeyData = Data(hexString: senderPubkey) else { throw NostrError.invalidCiphertext } let nonce24 = data.prefix(24) let rest = data.dropFirst(24) let tag = rest.suffix(16) let ct = rest.dropLast(16) // Try decryption with even-Y then odd-Y when sender pubkey is x-only func attemptDecrypt(using pubKeyData: Data) throws -> Data { let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData) let key = try deriveNIP44V2Key(from: ss) return try XChaCha20Poly1305Compat.open( ciphertext: Data(ct), tag: Data(tag), key: key, nonce24: Data(nonce24) ) } // If 32 bytes (x-only) try both parities, otherwise single try if senderPubkeyData.count == 32 { let even = Data([0x02]) + senderPubkeyData if let pt = try? attemptDecrypt(using: even) { return String(data: pt, encoding: .utf8) ?? "" } let odd = Data([0x03]) + senderPubkeyData let pt = try attemptDecrypt(using: odd) return String(data: pt, encoding: .utf8) ?? "" } else { let pt = try attemptDecrypt(using: senderPubkeyData) return String(data: pt, encoding: .utf8) ?? "" } } private static func deriveSharedSecret( privateKey: P256K.Schnorr.PrivateKey, publicKey: Data ) throws -> Data { // Deriving shared secret // Convert Schnorr private key to KeyAgreement private key let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey( dataRepresentation: privateKey.dataRepresentation ) // Create KeyAgreement public key from the public key data // For ECDH, we need the full 33-byte compressed public key (with 0x02 or 0x03 prefix) var fullPublicKey = Data() if publicKey.count == 32 { // X-only key, need to add prefix // For x-only keys in Nostr/Bitcoin, we need to try both possible Y coordinates // First try with even Y (0x02 prefix) fullPublicKey.append(0x02) fullPublicKey.append(publicKey) // Trying with even Y coordinate } else { fullPublicKey = publicKey } // Try to create public key, if it fails with even Y, try odd Y let keyAgreementPublicKey: P256K.KeyAgreement.PublicKey do { keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( dataRepresentation: fullPublicKey, format: .compressed ) } catch { if publicKey.count == 32 { // Try with odd Y (0x03 prefix) // Even Y failed, trying odd Y fullPublicKey = Data() fullPublicKey.append(0x03) fullPublicKey.append(publicKey) keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( dataRepresentation: fullPublicKey, format: .compressed ) } else { throw error } } // Perform ECDH let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement( with: keyAgreementPublicKey, format: .compressed ) // Convert SharedSecret to Data let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } // ECDH shared secret derived // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key return sharedSecretData } // Direct version that doesn't try to add prefixes private static func deriveSharedSecretDirect( privateKey: P256K.Schnorr.PrivateKey, publicKey: Data ) throws -> Data { // Direct shared secret calculation // Convert Schnorr private key to KeyAgreement private key let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey( dataRepresentation: privateKey.dataRepresentation ) // Use the public key as-is (should already have prefix) let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( dataRepresentation: publicKey, format: .compressed ) // Perform ECDH let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement( with: keyAgreementPublicKey, format: .compressed ) // Convert SharedSecret to Data let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key return sharedSecretData } private static func randomizedTimestamp() -> Date { // Add random offset to current time for privacy // 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) // Log with explicit UTC and local time for debugging let formatter = DateFormatter() // formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" formatter.timeZone = TimeZone(abbreviation: "UTC") formatter.timeZone = TimeZone.current // Timestamp randomized for privacy return randomized } } /// Nostr Event structure struct NostrEvent: Codable { var id: String let pubkey: String let created_at: Int let kind: Int let tags: [[String]] let content: String var sig: String? init( pubkey: String, createdAt: Date, kind: NostrProtocol.EventKind, tags: [[String]], content: String ) { self.pubkey = pubkey self.created_at = Int(createdAt.timeIntervalSince1970) self.kind = kind.rawValue self.tags = tags self.content = content self.sig = nil self.id = "" // Will be set during signing } init(from dict: [String: Any]) throws { guard let pubkey = dict["pubkey"] as? String, let createdAt = dict["created_at"] as? Int, let kind = dict["kind"] as? Int, let tags = dict["tags"] as? [[String]], let content = dict["content"] as? String else { throw NostrError.invalidEvent } self.id = dict["id"] as? String ?? "" self.pubkey = pubkey self.created_at = createdAt self.kind = kind self.tags = tags self.content = content self.sig = dict["sig"] as? String } func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent { let (eventId, eventIdHash) = try calculateEventId() // Sign with Schnorr (BIP-340) var messageBytes = [UInt8](eventIdHash) var auxRand = [UInt8](repeating: 0, count: 32) _ = auxRand.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) } let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand) let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString() var signed = self signed.id = eventId signed.sig = signatureHex return signed } /// Validate that the event ID and Schnorr signature match the content and pubkey. /// Returns false when the signature is missing, malformed, or does not verify. func isValidSignature() -> Bool { guard let sig = sig, let sigData = Data(hexString: sig), let pubData = Data(hexString: pubkey), sigData.count == 64, pubData.count == 32, let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData), let (expectedId, eventHash) = try? calculateEventId(), expectedId == id else { return false } var messageBytes = [UInt8](eventHash) let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData) return xonly.isValid(signature, for: &messageBytes) } private func calculateEventId() throws -> (String, Data) { let serialized = [ 0, pubkey, created_at, kind, tags, content ] as [Any] let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) return (data.sha256Fingerprint(), data.sha256Hash()) } func jsonString() throws -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.withoutEscapingSlashes] let data = try encoder.encode(self) return String(data: data, encoding: .utf8) ?? "" } } enum NostrError: Error { case invalidPublicKey case invalidPrivateKey case invalidEvent case invalidCiphertext case signingFailed case encryptionFailed } // MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url) private extension NostrProtocol { static func base64URLEncode(_ data: Data) -> String { return data.base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } static func base64URLDecode(_ s: String) -> Data? { var str = s let pad = (4 - (str.count % 4)) % 4 if pad > 0 { str += String(repeating: "=", count: pad) } str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") return Data(base64Encoded: str) } static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data { let derivedKey = HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: sharedSecretData), salt: Data(), info: "nip44-v2".data(using: .utf8)!, outputByteCount: 32 ) return derivedKey.withUnsafeBytes { Data($0) } } } ================================================ FILE: bitchat/Nostr/NostrRelayManager.swift ================================================ import BitLogger import Foundation import Network import Combine import Tor protocol NostrRelayConnectionProtocol: AnyObject { func resume() func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) func receive(completionHandler: @escaping (Result) -> Void) func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) } protocol NostrRelaySessionProtocol { func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol } private final class URLSessionWebSocketTaskAdapter: NostrRelayConnectionProtocol { private let base: URLSessionWebSocketTask init(base: URLSessionWebSocketTask) { self.base = base } func resume() { base.resume() } func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { base.cancel(with: closeCode, reason: reason) } func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) { base.send(message, completionHandler: completionHandler) } func receive(completionHandler: @escaping (Result) -> Void) { base.receive(completionHandler: completionHandler) } func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) { base.sendPing(pongReceiveHandler: pongReceiveHandler) } } private struct URLSessionAdapter: NostrRelaySessionProtocol { let base: URLSession func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol { URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url)) } } struct NostrRelayManagerDependencies { var activationAllowed: () -> Bool var userTorEnabled: () -> Bool var hasMutualFavorites: () -> Bool var hasLocationPermission: () -> Bool var mutualFavoritesPublisher: AnyPublisher, Never> var locationPermissionPublisher: AnyPublisher var torEnforced: () -> Bool var torIsReady: () -> Bool var torIsForeground: () -> Bool var awaitTorReady: (@escaping (Bool) -> Void) -> Void var makeSession: () -> NostrRelaySessionProtocol var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void var now: () -> Date } private extension NostrRelayManagerDependencies { @MainActor static func live() -> Self { Self( activationAllowed: { NetworkActivationService.shared.activationAllowed }, userTorEnabled: { NetworkActivationService.shared.userTorEnabled }, hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty }, hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized }, mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(), locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(), torEnforced: { TorManager.shared.torEnforced }, torIsReady: { TorManager.shared.isReady }, torIsForeground: { TorManager.shared.isForeground() }, awaitTorReady: { completion in Task.detached { let ready = await TorManager.shared.awaitReady() await MainActor.run { completion(ready) } } }, makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) }, scheduleAfter: { delay, action in DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) }, now: Date.init ) } } /// Manages WebSocket connections to Nostr relays @MainActor final class NostrRelayManager: ObservableObject { static let shared = NostrRelayManager() // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info private(set) static var pendingGiftWrapIDs = Set() static func registerPendingGiftWrap(id: String) { pendingGiftWrapIDs.insert(id) } struct Relay: Identifiable { let id = UUID() let url: String var isConnected: Bool = false var lastError: Error? var lastConnectedAt: Date? var messagesSent: Int = 0 var messagesReceived: Int = 0 var reconnectAttempts: Int = 0 var lastDisconnectedAt: Date? var nextReconnectTime: Date? } // Default relay list (can be customized) private static let defaultRelays = [ "wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net", "wss://offchain.pub", "wss://nostr21.com" // For local testing, you can add: "ws://localhost:8080" ] private static let defaultRelaySet = Set(defaultRelays) @Published private(set) var relays: [Relay] = [] @Published private(set) var isConnected = false private let dependencies: NostrRelayManagerDependencies private var allowDefaultRelays: Bool = false private var hasMutualFavorites: Bool = false private var hasLocationPermission: Bool = false private var connections: [String: NostrRelayConnectionProtocol] = [:] private var subscriptions: [String: Set] = [:] // relay URL -> active subscription IDs private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) private var messageHandlers: [String: (NostrEvent) -> Void] = [:] // Coalesce duplicate subscribe requests for the same id within a short window private var subscribeCoalesce: [String: Date] = [:] private var cancellables = Set() // Track EOSE per subscription to signal when initial stored events are done private struct EOSETracker { var pendingRelays: Set var callback: () -> Void var timer: Timer? } private var eoseTrackers: [String: EOSETracker] = [:] // Message queue for reliability // Pending sends held only for relays that are not yet connected. private struct PendingSend { var event: NostrEvent var pendingRelays: Set } private var messageQueue: [PendingSend] = [] private let messageQueueLock = NSLock() private let encoder = JSONEncoder() private var shouldUseTor: Bool { dependencies.userTorEnabled() } // Exponential backoff configuration private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts // Bump generation to invalidate scheduled reconnects when we reset/disconnect private var connectionGeneration: Int = 0 init() { self.dependencies = .live() hasMutualFavorites = dependencies.hasMutualFavorites() hasLocationPermission = dependencies.hasLocationPermission() applyDefaultRelayPolicy(force: true) // Deterministic JSON shape for outbound requests self.encoder.outputFormatting = .sortedKeys dependencies.mutualFavoritesPublisher .receive(on: DispatchQueue.main) .sink { [weak self] favorites in guard let self = self else { return } self.hasMutualFavorites = !favorites.isEmpty self.applyDefaultRelayPolicy() } .store(in: &cancellables) dependencies.locationPermissionPublisher .receive(on: DispatchQueue.main) .sink { [weak self] state in guard let self = self else { return } let authorized = (state == .authorized) if authorized == self.hasLocationPermission { return } self.hasLocationPermission = authorized self.applyDefaultRelayPolicy() } .store(in: &cancellables) } internal init(dependencies: NostrRelayManagerDependencies) { self.dependencies = dependencies hasMutualFavorites = dependencies.hasMutualFavorites() hasLocationPermission = dependencies.hasLocationPermission() applyDefaultRelayPolicy(force: true) // Deterministic JSON shape for outbound requests self.encoder.outputFormatting = .sortedKeys dependencies.mutualFavoritesPublisher .receive(on: DispatchQueue.main) .sink { [weak self] favorites in guard let self = self else { return } self.hasMutualFavorites = !favorites.isEmpty self.applyDefaultRelayPolicy() } .store(in: &cancellables) dependencies.locationPermissionPublisher .receive(on: DispatchQueue.main) .sink { [weak self] state in guard let self = self else { return } let authorized = (state == .authorized) if authorized == self.hasLocationPermission { return } self.hasLocationPermission = authorized self.applyDefaultRelayPolicy() } .store(in: &cancellables) } /// Connect to all configured relays func connect() { // Global network policy gate guard dependencies.activationAllowed() else { return } if shouldUseTor { // Ensure Tor is started early and wait for readiness off-main; then hop back to connect. dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } if !ready { SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session) return } SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session) for relay in self.relays { self.connectToRelay(relay.url) } } } else { SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session) for relay in self.relays { connectToRelay(relay.url) } } } /// Disconnect from all relays func disconnect() { connectionGeneration &+= 1 for (_, task) in connections { task.cancel(with: .goingAway, reason: nil) } connections.removeAll() // Clear known subscriptions and any queued subs since connections are gone subscriptions.removeAll() pendingSubscriptions.removeAll() updateConnectionStatus() } /// Ensure connections exist to the given relay URLs (idempotent). func ensureConnections(to relayUrls: [String]) { // Global network policy gate guard dependencies.activationAllowed() else { return } let targets = allowedRelayList(from: relayUrls) guard !targets.isEmpty else { return } if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { // Defer until Tor is fully ready; avoid queuing connection attempts early dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } if ready { self.ensureConnections(to: relayUrls) } } return } var existing = Set(relays.map { $0.url }) for url in targets where !existing.contains(url) { relays.append(Relay(url: url)) existing.insert(url) } for url in targets where connections[url] == nil { connectToRelay(url) } } /// Send an event to specified relays (or all if none specified) func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { // Global network policy gate guard dependencies.activationAllowed() else { return } if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { // Defer sends until Tor is ready to avoid premature queueing dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } if ready { self.sendEvent(event, to: relayUrls) } } return } let requestedRelays = relayUrls ?? Self.defaultRelays let targetRelays = allowedRelayList(from: requestedRelays) guard !targetRelays.isEmpty else { return } ensureConnections(to: targetRelays) // Attempt immediate send to relays with active connections; queue the rest var stillPending = Set() for relayUrl in targetRelays { if let connection = connections[relayUrl] { sendToRelay(event: event, connection: connection, relayUrl: relayUrl) } else { stillPending.insert(relayUrl) } } if !stillPending.isEmpty { messageQueueLock.lock() messageQueue.append(PendingSend(event: event, pendingRelays: stillPending)) messageQueueLock.unlock() } } /// Try to flush any queued messages for relays that are now connected. private func flushMessageQueue(for relayUrl: String? = nil) { messageQueueLock.lock() defer { messageQueueLock.unlock() } guard !messageQueue.isEmpty else { return } if let target = relayUrl { // Flush only for a specific relay for i in (0.. Void, onEOSE: (() -> Void)? = nil ) { // Global network policy gate guard dependencies.activationAllowed() else { return } // Coalesce rapid duplicate subscribe requests only if a handler already exists let now = dependencies.now() if messageHandlers[id] != nil { if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 { return } } subscribeCoalesce[id] = now if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { // Defer subscription setup until Tor is ready; avoid queuing subs early dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } if ready { self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE) } } return } messageHandlers[id] = handler let req = NostrRequest.subscribe(id: id, filters: [filter]) do { let message = try encoder.encode(req) guard let messageString = String(data: message, encoding: .utf8) else { SecureLogger.error("❌ Failed to encode subscription request", category: .session) return } // SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session) // Target specific relays if provided; else default. Filter permanently failed relays. let baseUrls = relayUrls ?? Self.defaultRelays let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) } let urls = allowedRelayList(from: candidateUrls) // Always queue subscriptions; sending happens when a relay reports connected let existingSet = Set(relays.map { $0.url }) for url in urls where !existingSet.contains(url) { relays.append(Relay(url: url)) } for url in urls { var map = self.pendingSubscriptions[url] ?? [:] map[id] = messageString self.pendingSubscriptions[url] = map } // Initialize EOSE tracking if requested if let onEOSE = onEOSE { if urls.isEmpty { onEOSE() } else { var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil) // Fallback timeout to avoid hanging if a relay never sends EOSE tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in Task { @MainActor in guard let self = self else { return } if let t = self.eoseTrackers[id] { t.timer?.invalidate() self.eoseTrackers.removeValue(forKey: id) onEOSE() } } } eoseTrackers[id] = tracker } } SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session) // Ensure we actually have sockets opening to these relays so queued REQs can flush ensureConnections(to: urls) // If some targets are already connected, flush immediately for them for url in urls { if let r = relays.first(where: { $0.url == url }), r.isConnected { flushPendingSubscriptions(for: url) } } } catch { SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session) } } private func applyDefaultRelayPolicy(force: Bool = false) { let shouldAllow = hasMutualFavorites || hasLocationPermission if !force && shouldAllow == allowDefaultRelays { return } allowDefaultRelays = shouldAllow if shouldAllow { var existing = Set(relays.map { $0.url }) for url in Self.defaultRelays where !existing.contains(url) { relays.append(Relay(url: url)) existing.insert(url) } if dependencies.activationAllowed() { ensureConnections(to: Self.defaultRelays) } } else { for url in Self.defaultRelays { if let connection = connections[url] { connection.cancel(with: .goingAway, reason: nil) } connections.removeValue(forKey: url) subscriptions.removeValue(forKey: url) pendingSubscriptions.removeValue(forKey: url) } messageQueueLock.lock() for index in (0.. [String] { var seen = Set() var result: [String] = [] for url in urls { if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue } if seen.insert(url).inserted { result.append(url) } } return result } /// Unsubscribe from a subscription func unsubscribe(id: String) { messageHandlers.removeValue(forKey: id) // Allow immediate re-subscription by clearing coalescer timestamp subscribeCoalesce.removeValue(forKey: id) let req = NostrRequest.close(id: id) let message = try? encoder.encode(req) guard let messageData = message, let messageString = String(data: messageData, encoding: .utf8) else { return } // Send unsubscribe to all relays for (relayUrl, connection) in connections { if subscriptions[relayUrl]?.contains(id) == true { subscriptions[relayUrl]?.remove(id) connection.send(.string(messageString)) { _ in // Local state is cleared before sending so callers can re-subscribe immediately. } } } } // MARK: - Private Methods private func connectToRelay(_ urlString: String) { // Global network policy gate guard dependencies.activationAllowed() else { return } guard let url = URL(string: urlString) else { SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session) return } // Avoid initiating connections while app is backgrounded; we'll reconnect on foreground if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() { return } // Skip if we already have a connection object if connections[urlString] != nil { return } if isPermanentlyFailed(urlString) { return } // Attempting to connect to Nostr relay via the proxied session // If Tor is enforced but not ready, delay connection until it is. if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } if ready { self.connectToRelay(urlString) } else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) } } return } let session = dependencies.makeSession() let task = session.webSocketTask(with: url) connections[urlString] = task task.resume() // Start receiving messages receiveMessage(from: task, relayUrl: urlString) // Send initial ping to verify connection task.sendPing { [weak self] error in DispatchQueue.main.async { if error == nil { SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session) self?.updateRelayStatus(urlString, isConnected: true) // Flush any pending subscriptions for this relay self?.flushPendingSubscriptions(for: urlString) } else { SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session) self?.updateRelayStatus(urlString, isConnected: false, error: error) // Trigger disconnection handler for proper backoff self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil)) } } } } /// Send any queued subscriptions for a relay that just connected. private func flushPendingSubscriptions(for relayUrl: String) { guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return } guard let connection = connections[relayUrl] else { return } for (id, messageString) in map { if self.subscriptions[relayUrl]?.contains(id) == true { continue } connection.send(.string(messageString)) { error in if let error = error { SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session) } else { Task { @MainActor in var subs = self.subscriptions[relayUrl] ?? Set() subs.insert(id) self.subscriptions[relayUrl] = subs } } } } pendingSubscriptions[relayUrl] = nil } private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) { task.receive { [weak self] result in guard let self = self else { return } switch result { case .success(let message): // Parse off-main to reduce UI jank, then hop back for state updates Task.detached(priority: .utility) { guard let parsed = ParsedInbound(message) else { return } await MainActor.run { self.handleParsedMessage(parsed, from: relayUrl) } } // Continue receiving Task { @MainActor in self.receiveMessage(from: task, relayUrl: relayUrl) } case .failure(let error): DispatchQueue.main.async { self.handleDisconnection(relayUrl: relayUrl, error: error) } } } } // Parsed inbound message type (off-main) // Note: declared at file scope below to avoid MainActor isolation inside this class // and keep parsing off the main actor. // Handle parsed message on MainActor (state updates and handlers) private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) { switch parsed { case .event(let subId, let event): if event.kind != 1059 { SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session) } if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { self.relays[index].messagesReceived += 1 } if let handler = self.messageHandlers[subId] { handler(event) } else { SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session) } case .eose(let subId): if var tracker = eoseTrackers[subId] { tracker.pendingRelays.remove(relayUrl) if tracker.pendingRelays.isEmpty { tracker.timer?.invalidate() eoseTrackers.removeValue(forKey: subId) tracker.callback() } else { eoseTrackers[subId] = tracker } } case .ok(let eventId, let success, let reason): if success { _ = Self.pendingGiftWrapIDs.remove(eventId) SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session) } else { let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil if isGiftWrap { SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session) } else { SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session) } } case .notice: break } } private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) { let req = NostrRequest.event(event) do { let data = try encoder.encode(req) let message = String(data: data, encoding: .utf8) ?? "" SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session) connection.send(.string(message)) { [weak self] error in DispatchQueue.main.async { if let error = error { SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session) } else { // SecureLogger.debug("✅ Event sent to relay: \(relayUrl)", category: .session) // Update relay stats if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { self?.relays[index].messagesSent += 1 } } } } } catch { SecureLogger.error("Failed to encode event: \(error)", category: .session) } } private func updateRelayStatus(_ url: String, isConnected: Bool, error: Error? = nil) { if let index = relays.firstIndex(where: { $0.url == url }) { relays[index].isConnected = isConnected relays[index].lastError = error if isConnected { relays[index].lastConnectedAt = dependencies.now() relays[index].reconnectAttempts = 0 // Reset on successful connection relays[index].nextReconnectTime = nil } else { relays[index].lastDisconnectedAt = dependencies.now() } } updateConnectionStatus() // If we just connected to this relay, flush any queued sends targeting it if isConnected { flushMessageQueue(for: url) } } private func updateConnectionStatus() { isConnected = relays.contains { $0.isConnected } } private func handleDisconnection(relayUrl: String, error: Error) { // If networking is disallowed, do not schedule reconnection if !dependencies.activationAllowed() { connections.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl) updateRelayStatus(relayUrl, isConnected: false, error: error) return } connections.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl) updateRelayStatus(relayUrl, isConnected: false, error: error) // Check if this is a DNS or handshake error; treat as permanent let errorDescription = error.localizedDescription.lowercased() let ns = error as NSError if errorDescription.contains("hostname could not be found") || errorDescription.contains("dns") || (ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) { if relays.first(where: { $0.url == relayUrl })?.lastError == nil { SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session) } if let index = relays.firstIndex(where: { $0.url == relayUrl }) { relays[index].lastError = error relays[index].reconnectAttempts = maxReconnectAttempts relays[index].nextReconnectTime = nil } pendingSubscriptions[relayUrl] = nil return } // Implement exponential backoff for non-DNS errors guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return } relays[index].reconnectAttempts += 1 // Stop attempting after max attempts if relays[index].reconnectAttempts >= maxReconnectAttempts { SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session) return } // Calculate backoff interval let backoffInterval = min( initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)), maxBackoffInterval ) let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval) relays[index].nextReconnectTime = nextReconnectTime // Schedule reconnection with exponential backoff let gen = connectionGeneration dependencies.scheduleAfter(backoffInterval) { [weak self] in Task { @MainActor [weak self] in guard let self = self else { return } // Ignore stale scheduled reconnects from a previous generation guard gen == self.connectionGeneration else { return } // Check if we should still reconnect (relay might have been removed) if self.relays.contains(where: { $0.url == relayUrl }) { self.connectToRelay(relayUrl) } } } } // MARK: - Public Utility Methods /// Manually retry connection to a specific relay func retryConnection(to relayUrl: String) { guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return } // Reset reconnection attempts relays[index].reconnectAttempts = 0 relays[index].nextReconnectTime = nil relays[index].lastError = nil // Disconnect if connected if let connection = connections[relayUrl] { connection.cancel(with: .goingAway, reason: nil) connections.removeValue(forKey: relayUrl) } // Attempt immediate reconnection connectToRelay(relayUrl) } /// Get detailed status for all relays func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] { return relays.map { relay in (url: relay.url, isConnected: relay.isConnected, reconnectAttempts: relay.reconnectAttempts, nextReconnectTime: relay.nextReconnectTime) } } var debugPendingMessageQueueCount: Int { messageQueueLock.lock() defer { messageQueueLock.unlock() } return messageQueue.count } func debugPendingSubscriptionCount(for relayUrl: String) -> Int { pendingSubscriptions[relayUrl]?.count ?? 0 } func debugFlushMessageQueue() { flushMessageQueue(for: nil) } /// Reset all relay connections func resetAllConnections() { disconnect() // New generation begins now connectionGeneration &+= 1 // Reset all relay states for index in relays.indices { relays[index].reconnectAttempts = 0 relays[index].nextReconnectTime = nil relays[index].lastError = nil } // Reconnect connect() } // MARK: - Failure classification private func isPermanentlyFailed(_ url: String) -> Bool { guard let r = relays.first(where: { $0.url == url }) else { return false } if r.reconnectAttempts >= maxReconnectAttempts { return true } if let ns = r.lastError as NSError?, ns.domain == NSURLErrorDomain { if ns.code == NSURLErrorBadServerResponse || ns.code == NSURLErrorCannotFindHost { return true } } return false } } // MARK: - Off-main inbound parsing helpers (file scope, non-isolated) private enum ParsedInbound { case event(subId: String, event: NostrEvent) case ok(eventId: String, success: Bool, reason: String) case eose(subscriptionId: String) case notice(String) init?(_ message: URLSessionWebSocketTask.Message) { guard let data = message.data, let array = try? JSONSerialization.jsonObject(with: data) as? [Any], array.count >= 2, let type = array[0] as? String else { return nil } switch type { case "EVENT": if array.count >= 3, let subId = array[1] as? String, let eventDict = array[2] as? [String: Any], let event = try? NostrEvent(from: eventDict), event.isValidSignature() { self = .event(subId: subId, event: event) return } return nil case "EOSE": if let subId = array[1] as? String { self = .eose(subscriptionId: subId) return } return nil case "OK": if array.count >= 3, 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" self = .ok(eventId: eventId, success: success, reason: reason) return } return nil case "NOTICE": if array.count >= 2, let msg = array[1] as? String { self = .notice(msg) return } return nil default: return nil } } } private extension URLSessionWebSocketTask.Message { var data: Data? { switch self { case .string(let text): text.data(using: .utf8) case .data(let data): data @unknown default: nil } } } // MARK: - Nostr Protocol Types enum NostrRequest: Encodable { case event(NostrEvent) case subscribe(id: String, filters: [NostrFilter]) case close(id: String) func encode(to encoder: Encoder) throws { var container = encoder.unkeyedContainer() switch self { case .event(let event): try container.encode("EVENT") try container.encode(event) case .subscribe(let id, let filters): try container.encode("REQ") try container.encode(id) for filter in filters { try container.encode(filter) } case .close(let id): try container.encode("CLOSE") try container.encode(id) } } } struct NostrFilter: Encodable { var ids: [String]? var authors: [String]? var kinds: [Int]? var since: Int? var until: Int? var limit: Int? // Tag filters - stored internally but encoded specially fileprivate var tagFilters: [String: [String]]? init() { // Default initializer } // Custom encoding to handle tag filters properly enum CodingKeys: String, CodingKey { case ids, authors, kinds, since, until, limit } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: DynamicCodingKey.self) // Encode standard fields if let ids = ids { try container.encode(ids, forKey: DynamicCodingKey(stringValue: "ids")) } if let authors = authors { try container.encode(authors, forKey: DynamicCodingKey(stringValue: "authors")) } if let kinds = kinds { try container.encode(kinds, forKey: DynamicCodingKey(stringValue: "kinds")) } if let since = since { try container.encode(since, forKey: DynamicCodingKey(stringValue: "since")) } if let until = until { try container.encode(until, forKey: DynamicCodingKey(stringValue: "until")) } if let limit = limit { try container.encode(limit, forKey: DynamicCodingKey(stringValue: "limit")) } // Encode tag filters with # prefix if let tagFilters = tagFilters { for (tag, values) in tagFilters { try container.encode(values, forKey: DynamicCodingKey(stringValue: "#\(tag)")) } } } // For NIP-17 gift wraps static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter { var filter = NostrFilter() filter.kinds = [1059] // Gift wrap kind filter.since = since?.timeIntervalSince1970.toInt() filter.tagFilters = ["p": [pubkey]] filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit return filter } // For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001) static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter { var filter = NostrFilter() filter.kinds = [20000, 20001] filter.since = since?.timeIntervalSince1970.toInt() filter.tagFilters = ["g": [geohash]] filter.limit = limit return filter } // For location notes: persistent text notes (kind 1) tagged with geohash static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter { var filter = NostrFilter() filter.kinds = [1] filter.since = since?.timeIntervalSince1970.toInt() filter.tagFilters = ["g": [geohash]] filter.limit = limit return filter } // For location notes with neighbors: subscribe to multiple geohashes (center + neighbors) static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter { var filter = NostrFilter() filter.kinds = [1] filter.since = since?.timeIntervalSince1970.toInt() filter.tagFilters = ["g": geohashes] filter.limit = limit return filter } } // Dynamic coding key for tag filters private struct DynamicCodingKey: CodingKey { var stringValue: String var intValue: Int? { nil } init(stringValue: String) { self.stringValue = stringValue } init?(intValue: Int) { return nil } } private extension TimeInterval { func toInt() -> Int { return Int(self) } } ================================================ FILE: bitchat/Nostr/XChaCha20Poly1305Compat.swift ================================================ import Foundation import CryptoKit /// Minimal XChaCha20-Poly1305 compatibility wrapper using CryptoKit's ChaChaPoly. /// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce /// as per XChaCha20 construction. enum XChaCha20Poly1305Compat { /// Errors that can occur during XChaCha20-Poly1305 operations enum Error: Swift.Error { case invalidKeyLength(expected: Int, got: Int) case invalidNonceLength(expected: Int, got: Int) } struct SealBox { let ciphertext: Data let tag: Data } static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox { guard key.count == 32 else { throw Error.invalidKeyLength(expected: 32, got: key.count) } guard nonce24.count == 24 else { throw Error.invalidNonceLength(expected: 24, got: nonce24.count) } let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16))) let nonce12 = derive12ByteNonce(from24: nonce24) let chachaKey = SymmetricKey(data: subkey) let nonce = try ChaChaPoly.Nonce(data: nonce12) let sealed = try ChaChaPoly.seal(plaintext, using: chachaKey, nonce: nonce, authenticating: aad ?? Data()) return SealBox(ciphertext: sealed.ciphertext, tag: sealed.tag) } static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data { guard key.count == 32 else { throw Error.invalidKeyLength(expected: 32, got: key.count) } guard nonce24.count == 24 else { throw Error.invalidNonceLength(expected: 24, got: nonce24.count) } let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16))) let nonce12 = derive12ByteNonce(from24: nonce24) let chachaKey = SymmetricKey(data: subkey) let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag) return try ChaChaPoly.open(box, using: chachaKey, authenticating: aad ?? Data()) } // MARK: - Internals private static func derive12ByteNonce(from24 nonce24: Data) -> Data { // XChaCha20-Poly1305: 12-byte nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce var out = Data(count: 12) out.replaceSubrange(0..<4, with: [0, 0, 0, 0]) out.replaceSubrange(4..<12, with: nonce24.suffix(8)) return out } private static func hchacha20(key: Data, nonce16: Data) throws -> Data { // HChaCha20 based on the original ChaCha20 core with a 16-byte nonce. guard key.count == 32 else { throw Error.invalidKeyLength(expected: 32, got: key.count) } guard nonce16.count == 16 else { throw Error.invalidNonceLength(expected: 16, got: nonce16.count) } // Constants "expand 32-byte k" var state: [UInt32] = [ 0x61707865, 0x3320646e, 0x79622d32, 0x6b206574, // key (8 words) key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12), key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28), // nonce (4 words) nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12) ] // 20 rounds (10 double rounds) for _ in 0..<10 { // Column rounds quarterRound(&state, 0, 4, 8, 12) quarterRound(&state, 1, 5, 9, 13) quarterRound(&state, 2, 6, 10, 14) quarterRound(&state, 3, 7, 11, 15) // Diagonal rounds quarterRound(&state, 0, 5, 10, 15) quarterRound(&state, 1, 6, 11, 12) quarterRound(&state, 2, 7, 8, 13) quarterRound(&state, 3, 4, 9, 14) } // Output subkey: state[0..3] and state[12..15] var out = Data(count: 32) out.storeLEWord(state[0], at: 0) out.storeLEWord(state[1], at: 4) out.storeLEWord(state[2], at: 8) out.storeLEWord(state[3], at: 12) out.storeLEWord(state[12], at: 16) out.storeLEWord(state[13], at: 20) out.storeLEWord(state[14], at: 24) out.storeLEWord(state[15], at: 28) return out } private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) { s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 16) | (s[d] >> 16) s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 12) | (s[b] >> 20) s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 8) | (s[d] >> 24) s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 7) | (s[b] >> 25) } } private extension Data { func loadLEWord(_ offset: Int) -> UInt32 { let range = offset..<(offset+4) let bytes = self[range] return bytes.withUnsafeBytes { ptr -> UInt32 in let b = ptr.bindMemory(to: UInt8.self) return UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24) } } mutating func storeLEWord(_ value: UInt32, at offset: Int) { let bytes: [UInt8] = [ UInt8(value & 0xff), UInt8((value >> 8) & 0xff), UInt8((value >> 16) & 0xff), UInt8((value >> 24) & 0xff) ] replaceSubrange(offset..<(offset+4), with: bytes) } } ================================================ FILE: bitchat/Protocols/BinaryEncodingUtils.swift ================================================ // // BinaryEncodingUtils.swift // bitchat // // Binary encoding utilities for efficient protocol messages // import Foundation import CryptoKit // MARK: - Hex Encoding/Decoding extension Data { func hexEncodedString() -> String { if self.isEmpty { return "" } return self.map { String(format: "%02x", $0) }.joined() } func sha256Hex() -> String { let digest = SHA256.hash(data: self) return digest.map { String(format: "%02x", $0) }.joined() } /// Initialize Data from a hex string. /// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X". /// Whitespace is trimmed. Must have even length after prefix removal. /// - Returns: nil if the string has odd length or contains invalid hex characters. init?(hexString: String) { var hex = hexString.trimmingCharacters(in: .whitespaces) // Remove optional 0x prefix if hex.hasPrefix("0x") || hex.hasPrefix("0X") { hex = String(hex.dropFirst(2)) } // Reject odd-length strings guard hex.count % 2 == 0 else { return nil } // Reject empty strings guard !hex.isEmpty else { self = Data() return } let len = hex.count / 2 var data = Data(capacity: len) var index = hex.startIndex for _ in 0..> 8) & 0xFF)) self.append(UInt8(value & 0xFF)) } @inlinable mutating func appendUInt32(_ value: UInt32) { self.append(UInt8((value >> 24) & 0xFF)) self.append(UInt8((value >> 16) & 0xFF)) self.append(UInt8((value >> 8) & 0xFF)) self.append(UInt8(value & 0xFF)) } @inlinable mutating func appendUInt64(_ value: UInt64) { for i in (0..<8).reversed() { self.append(UInt8((value >> (i * 8)) & 0xFF)) } } mutating func appendString(_ string: String, maxLength: Int = 255) { guard let data = string.data(using: .utf8) else { return } let length = Swift.min(data.count, maxLength) if maxLength <= 255 { self.append(UInt8(length)) } else { self.appendUInt16(UInt16(length)) } self.append(data.prefix(length)) } mutating func appendData(_ data: Data, maxLength: Int = 65535) { let length = Swift.min(data.count, maxLength) if maxLength <= 255 { self.append(UInt8(length)) } else { self.appendUInt16(UInt16(length)) } self.append(data.prefix(length)) } mutating func appendDate(_ date: Date) { let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds self.appendUInt64(timestamp) } mutating func appendUUID(_ uuid: String) { // Convert UUID string to 16 bytes var uuidData = Data(count: 16) let cleanUUID = uuid.replacingOccurrences(of: "-", with: "") var index = cleanUUID.startIndex for i in 0..<16 { guard index < cleanUUID.endIndex else { break } let nextIndex = cleanUUID.index(index, offsetBy: 2) if let byte = UInt8(String(cleanUUID[index.. UInt8? { guard offset >= 0 && offset < self.count else { return nil } let value = self[offset] offset += 1 return value } @inlinable func readUInt16(at offset: inout Int) -> UInt16? { guard offset + 2 <= self.count else { return nil } let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1]) offset += 2 return value } @inlinable func readUInt32(at offset: inout Int) -> UInt32? { guard offset + 4 <= self.count else { return nil } let value = UInt32(self[offset]) << 24 | UInt32(self[offset + 1]) << 16 | UInt32(self[offset + 2]) << 8 | UInt32(self[offset + 3]) offset += 4 return value } @inlinable func readUInt64(at offset: inout Int) -> UInt64? { guard offset + 8 <= self.count else { return nil } var value: UInt64 = 0 for i in 0..<8 { value = (value << 8) | UInt64(self[offset + i]) } offset += 8 return value } func readString(at offset: inout Int, maxLength: Int = 255) -> String? { let length: Int if maxLength <= 255 { guard let len = readUInt8(at: &offset) else { return nil } length = Int(len) } else { guard let len = readUInt16(at: &offset) else { return nil } length = Int(len) } guard offset + length <= self.count else { return nil } let stringData = self[offset.. Data? { let length: Int if maxLength <= 255 { guard let len = readUInt8(at: &offset) else { return nil } length = Int(len) } else { guard let len = readUInt16(at: &offset) else { return nil } length = Int(len) } guard offset + length <= self.count else { return nil } let data = self[offset.. Date? { guard let timestamp = readUInt64(at: &offset) else { return nil } return Date(timeIntervalSince1970: Double(timestamp) / 1000.0) } func readUUID(at offset: inout Int) -> String? { guard offset + 16 <= self.count else { return nil } let uuidData = self[offset.. Data? { guard offset + count <= self.count else { return nil } let data = self[offset.. // /// /// # BinaryProtocol /// /// Low-level binary encoding and decoding for BitChat protocol messages. /// Optimized for Bluetooth LE's limited bandwidth and MTU constraints. /// /// ## Overview /// BinaryProtocol implements an efficient binary wire format that minimizes /// overhead while maintaining extensibility. It handles: /// - Compact binary encoding with fixed headers /// - Optional field support via flags /// - Automatic compression for large payloads /// - Endianness handling for cross-platform compatibility /// /// ## Wire Format /// ``` /// Header (Fixed 14 bytes for v1, 16 bytes for v2): /// +--------+------+-----+-----------+-------+------------------+ /// |Version | Type | TTL | Timestamp | Flags | PayloadLength | /// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes | /// +--------+------+-----+-----------+-------+------------------+ /// /// Variable sections: /// +----------+-------------+---------+------------+ /// | SenderID | RecipientID | Payload | Signature | /// | 8 bytes | 8 bytes* | Variable| 64 bytes* | /// +----------+-------------+---------+------------+ /// * Optional fields based on flags /// ``` /// /// ## Design Rationale /// The protocol is designed for: /// - **Efficiency**: Minimal overhead for small messages /// - **Flexibility**: Optional fields via flag bits /// - **Compatibility**: Network byte order (big-endian) /// - **Performance**: Zero-copy where possible /// /// ## Compression Strategy /// - Automatic compression for payloads > 256 bytes /// - zlib compression for broad compatibility on Apple platforms /// - Original size stored for decompression /// - Flag bit indicates compressed payload /// /// ## Flag Bits /// - Bit 0: Has recipient ID (directed message) /// - Bit 1: Has signature (authenticated message) /// - Bit 2: Is compressed (zlib compression applied) /// - Bits 3-7: Reserved for future use /// /// ## Size Constraints /// - Maximum packet size: 65,535 bytes (16-bit length field) /// - Typical packet size: < 512 bytes (BLE MTU) /// - Minimum packet size: 21 bytes (header + sender ID) /// /// ## Encoding Process /// 1. Construct header with fixed fields /// 2. Set appropriate flags /// 3. Compress payload if beneficial /// 4. Append variable-length fields /// 5. Calculate and append signature if needed /// /// ## Decoding Process /// 1. Validate minimum packet size /// 2. Parse fixed header /// 3. Extract flags and determine field presence /// 4. Parse variable fields based on flags /// 5. Decompress payload if compressed /// 6. Verify signature if present /// /// ## Error Handling /// - Graceful handling of malformed packets /// - Clear error messages for debugging /// - No crashes on invalid input /// - Logging of protocol violations /// /// ## Performance Notes /// - Allocation-free for small messages /// - Streaming support for large payloads /// - Efficient bit manipulation /// - Platform-optimized byte swapping /// import Foundation import BitLogger extension Data { func trimmingNullBytes() -> Data { // Find the first null byte if let nullIndex = self.firstIndex(of: 0) { return self.prefix(nullIndex) } return self } } /// Implements binary encoding and decoding for BitChat protocol messages. /// Provides static methods for converting between BitchatPacket objects and /// their binary wire format representation. /// - Note: All multi-byte values use network byte order (big-endian) struct BinaryProtocol { static let v1HeaderSize = 14 static let v2HeaderSize = 16 static let senderIDSize = 8 static let recipientIDSize = 8 static let signatureSize = 64 // Field offsets within packet header struct Offsets { static let version = 0 static let type = 1 static let ttl = 2 static let timestamp = 3 static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8) } static func headerSize(for version: UInt8) -> Int? { switch version { case 1: return v1HeaderSize case 2: return v2HeaderSize default: return nil } } private static func lengthFieldSize(for version: UInt8) -> Int { return version == 2 ? 4 : 2 } struct Flags { static let hasRecipient: UInt8 = 0x01 static let hasSignature: UInt8 = 0x02 static let isCompressed: UInt8 = 0x04 static let hasRoute: UInt8 = 0x08 static let isRSR: UInt8 = 0x10 } // Encode BitchatPacket to binary format static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? { let version = packet.version guard version == 1 || version == 2 else { return nil } // Try to compress payload when beneficial, keeping original size for later decoding var payload = packet.payload var isCompressed = false var originalPayloadSize: Int? if CompressionUtil.shouldCompress(payload) { // Only compress when we can represent the original length in the outbound frame let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max) if payload.count <= maxRepresentable, let compressedPayload = CompressionUtil.compress(payload) { originalPayloadSize = payload.count payload = compressedPayload isCompressed = true } } let lengthFieldBytes = lengthFieldSize(for: version) // Route is only supported for v2+ packets (per SOURCE_ROUTING.md spec) let originalRoute = (version >= 2) ? (packet.route ?? []) : [] if originalRoute.contains(where: { $0.isEmpty }) { return nil } let sanitizedRoute: [Data] = originalRoute.map { hop in if hop.count == senderIDSize { return hop } if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) } var padded = hop padded.append(Data(repeating: 0, count: senderIDSize - hop.count)) return padded } guard sanitizedRoute.count <= 255 else { return nil } let hasRoute = !sanitizedRoute.isEmpty let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0 let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0 // payloadLength in header is payload-only (does NOT include route bytes) let payloadDataSize = payload.count + originalSizeFieldBytes if version == 1 && payloadDataSize > Int(UInt16.max) { return nil } if version == 2 && payloadDataSize > Int(UInt32.max) { return nil } guard let headerSize = headerSize(for: version) else { return nil } let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + routeLength let estimatedPayload = payloadDataSize let estimatedSignature = (packet.signature == nil ? 0 : signatureSize) var data = Data() data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255) data.append(version) data.append(packet.type) data.append(packet.ttl) for shift in stride(from: 56, through: 0, by: -8) { data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF)) } var flags: UInt8 = 0 if packet.recipientID != nil { flags |= Flags.hasRecipient } if packet.signature != nil { flags |= Flags.hasSignature } if isCompressed { flags |= Flags.isCompressed } // HAS_ROUTE is only valid for v2+ packets if hasRoute && version >= 2 { flags |= Flags.hasRoute } if packet.isRSR { flags |= Flags.isRSR } data.append(flags) if version == 2 { let length = UInt32(payloadDataSize) for shift in stride(from: 24, through: 0, by: -8) { data.append(UInt8((length >> UInt32(shift)) & 0xFF)) } } else { let length = UInt16(payloadDataSize) data.append(UInt8((length >> 8) & 0xFF)) data.append(UInt8(length & 0xFF)) } let senderBytes = packet.senderID.prefix(senderIDSize) data.append(senderBytes) if senderBytes.count < senderIDSize { data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count)) } if let recipientID = packet.recipientID { let recipientBytes = recipientID.prefix(recipientIDSize) data.append(recipientBytes) if recipientBytes.count < recipientIDSize { data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count)) } } if hasRoute { data.append(UInt8(sanitizedRoute.count)) for hop in sanitizedRoute { data.append(hop) } } if isCompressed, let originalSize = originalPayloadSize { if version == 2 { let value = UInt32(originalSize) for shift in stride(from: 24, through: 0, by: -8) { data.append(UInt8((value >> UInt32(shift)) & 0xFF)) } } else { let value = UInt16(originalSize) data.append(UInt8((value >> 8) & 0xFF)) data.append(UInt8(value & 0xFF)) } } data.append(payload) if let signature = packet.signature { data.append(signature.prefix(signatureSize)) } if padding { let optimalSize = MessagePadding.optimalBlockSize(for: data.count) return MessagePadding.pad(data, toSize: optimalSize) } return data } // Decode binary data to BitchatPacket static func decode(_ data: Data) -> BitchatPacket? { // Try decode as-is first (robust when padding wasn't applied) if let pkt = decodeCore(data) { return pkt } // If that fails, try after removing padding let unpadded = MessagePadding.unpad(data) if unpadded as NSData === data as NSData { return nil } return decodeCore(unpadded) } // Core decoding implementation used by decode(_:) with and without padding removal private static func decodeCore(_ raw: Data) -> BitchatPacket? { guard raw.count >= v1HeaderSize + senderIDSize else { return nil } return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in guard let base = buf.baseAddress else { return nil } var offset = 0 func require(_ n: Int) -> Bool { offset + n <= buf.count } func read8() -> UInt8? { guard require(1) else { return nil } let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee offset += 1 return value } func read16() -> UInt16? { guard require(2) else { return nil } let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self) let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1]) offset += 2 return value } func read32() -> UInt32? { guard require(4) else { return nil } let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self) let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3]) offset += 4 return value } func readData(_ n: Int) -> Data? { guard require(n) else { return nil } let ptr = base.advanced(by: offset) let data = Data(bytes: ptr, count: n) offset += n return data } guard let version = read8(), version == 1 || version == 2 else { return nil } let lengthFieldBytes = lengthFieldSize(for: version) guard let headerSize = headerSize(for: version) else { return nil } let minimumRequired = headerSize + senderIDSize guard raw.count >= minimumRequired else { return nil } guard let type = read8(), let ttl = read8() else { return nil } var timestamp: UInt64 = 0 for _ in 0..<8 { guard let byte = read8() else { return nil } timestamp = (timestamp << 8) | UInt64(byte) } guard let flags = read8() else { return nil } let hasRecipient = (flags & Flags.hasRecipient) != 0 let hasSignature = (flags & Flags.hasSignature) != 0 let isCompressed = (flags & Flags.isCompressed) != 0 // HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1 let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0 let isRSR = (flags & Flags.isRSR) != 0 let payloadLength: Int if version == 2 { guard let len = read32() else { return nil } payloadLength = Int(len) } else { guard let len = read16() else { return nil } payloadLength = Int(len) } guard payloadLength >= 0 else { return nil } guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil } guard let senderID = readData(senderIDSize) else { return nil } var recipientID: Data? = nil if hasRecipient { recipientID = readData(recipientIDSize) if recipientID == nil { return nil } } // Route (optional, v2+ only): route bytes are NOT included in payloadLength var route: [Data]? = nil if hasRoute { guard let routeCount = read8() else { return nil } if routeCount > 0 { var hops: [Data] = [] for _ in 0..= lengthFieldBytes else { return nil } let originalSize: Int if version == 2 { guard let rawSize = read32() else { return nil } originalSize = Int(rawSize) } else { guard let rawSize = read16() else { return nil } originalSize = Int(rawSize) } guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil } let compressedSize = payloadLength - lengthFieldBytes guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil } let compressionRatio = Double(originalSize) / Double(compressedSize) guard compressionRatio <= 50_000.0 else { SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security) return nil } guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize), decompressed.count == originalSize else { return nil } payload = decompressed } else { guard let rawPayload = readData(payloadLength) else { return nil } payload = rawPayload } var signature: Data? = nil if hasSignature { signature = readData(signatureSize) if signature == nil { return nil } } guard offset <= buf.count else { return nil } return BitchatPacket( type: type, senderID: senderID, recipientID: recipientID, timestamp: timestamp, payload: payload, signature: signature, ttl: ttl, version: version, route: route, isRSR: isRSR ) } } } ================================================ FILE: bitchat/Protocols/BitchatFilePacket.swift ================================================ // // BitchatFilePacket.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation import BitLogger /// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files). /// Mirrors the Android client specification to ensure cross-platform interoperability. struct BitchatFilePacket { var fileName: String? var fileSize: UInt64? var mimeType: String? var content: Data /// Canonical TLV tags defined by the Android implementation. private enum TLVType: UInt8 { case fileName = 0x01 case fileSize = 0x02 case mimeType = 0x03 case content = 0x04 } /// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length). /// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max). func encode() -> Data? { let resolvedSize = fileSize ?? UInt64(content.count) guard resolvedSize <= UInt64(UInt32.max) else { return nil } guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil } guard content.count <= Int(UInt32.max) else { return nil } guard FileTransferLimits.isValidPayload(content.count) else { return nil } func appendBE(_ value: T, into data: inout Data) { var big = value.bigEndian withUnsafeBytes(of: &big) { data.append(contentsOf: $0) } } var encoded = Data() if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) { encoded.append(TLVType.fileName.rawValue) appendBE(UInt16(nameData.count), into: &encoded) encoded.append(nameData) } encoded.append(TLVType.fileSize.rawValue) appendBE(UInt16(4), into: &encoded) appendBE(UInt32(resolvedSize), into: &encoded) if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) { encoded.append(TLVType.mimeType.rawValue) appendBE(UInt16(mimeData.count), into: &encoded) encoded.append(mimeData) } encoded.append(TLVType.content.rawValue) appendBE(UInt32(content.count), into: &encoded) encoded.append(content) return encoded } /// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible. static func decode(_ data: Data) -> BitchatFilePacket? { var cursor = data.startIndex let end = data.endIndex var fileName: String? var fileSize: UInt64? var mimeType: String? var content = Data() while cursor < end { let typeRaw = data[cursor] cursor = data.index(after: cursor) guard cursor <= end else { return nil } let tlvType = TLVType(rawValue: typeRaw) func readBigEndianLength(bytes: Int) -> Int? { guard data.distance(from: cursor, to: end) >= bytes else { return nil } // Use UInt64 to prevent integer overflow during shift operations var result: UInt64 = 0 for _ in 0..= 0 else { return nil } guard data.distance(from: cursor, to: end) >= tlvLength else { return nil } let valueStart = cursor cursor = data.index(cursor, offsetBy: tlvLength) let value = data[valueStart.. UInt64(FileTransferLimits.maxPayloadBytes) { return nil } fileSize = size } case .mimeType: mimeType = String(data: Data(value), encoding: .utf8) case .content: let proposedSize = content.count + value.count if proposedSize > FileTransferLimits.maxPayloadBytes { return nil } content.append(contentsOf: value) case nil: continue } } guard !content.isEmpty else { return nil } guard FileTransferLimits.isValidPayload(content.count) else { return nil } return BitchatFilePacket( fileName: fileName, fileSize: fileSize ?? UInt64(content.count), mimeType: mimeType, content: content ) } } ================================================ FILE: bitchat/Protocols/BitchatProtocol.swift ================================================ // // BitchatProtocol.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // /// /// # BitchatProtocol /// /// Defines the application-layer protocol for BitChat mesh networking, including /// message types, packet structures, and encoding/decoding logic. /// /// ## Overview /// BitchatProtocol implements a binary protocol optimized for Bluetooth LE's /// constrained bandwidth and MTU limitations. It provides: /// - Efficient binary message encoding /// - Message fragmentation for large payloads /// - TTL-based routing for mesh networks /// - Privacy features like padding and timing obfuscation /// - Integration points for end-to-end encryption /// /// ## Protocol Design /// The protocol uses a compact binary format to minimize overhead: /// - 1-byte message type identifier /// - Variable-length fields with length prefixes /// - Network byte order (big-endian) for multi-byte values /// - PKCS#7-style padding for privacy /// /// ## Message Flow /// 1. **Creation**: Messages are created with type, content, and metadata /// 2. **Encoding**: Converted to binary format with proper field ordering /// 3. **Fragmentation**: Split if larger than BLE MTU (512 bytes) /// 4. **Transmission**: Sent via BLEService /// 5. **Routing**: Relayed by intermediate nodes (TTL decrements) /// 6. **Reassembly**: Fragments collected and reassembled /// 7. **Decoding**: Binary data parsed back to message objects /// /// ## Security Considerations /// - Message padding obscures actual content length /// - Timing obfuscation prevents traffic analysis /// - Integration with Noise Protocol for E2E encryption /// - No persistent identifiers in protocol headers /// /// ## Message Types /// - **Announce/Leave**: Peer presence notifications /// - **Message**: User chat messages (broadcast or directed) /// - **Fragment**: Multi-part message handling /// - **Delivery/Read**: Message acknowledgments /// - **Noise**: Encrypted channel establishment /// - **Version**: Protocol version negotiation /// /// ## Future Extensions /// The protocol is designed to be extensible: /// - Reserved message type ranges for future use /// - Version field for protocol evolution /// - Optional fields for new features /// import Foundation import CoreBluetooth // MARK: - Message Types /// 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 { // Public messages (unencrypted) case announce = 0x01 // "I'm here" with nickname case message = 0x02 // Public chat message case leave = 0x03 // "I'm leaving" case requestSync = 0x21 // GCS filter-based sync request (local-only) // Noise encryption case noiseHandshake = 0x10 // Handshake (init or response determined by payload) case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.) // Fragmentation (simplified) case fragment = 0x20 // Single fragment type for large messages case fileTransfer = 0x22 // Binary file/audio/image payloads var description: String { switch self { case .announce: return "announce" case .message: return "message" case .leave: return "leave" case .requestSync: return "requestSync" case .noiseHandshake: return "noiseHandshake" case .noiseEncrypted: return "noiseEncrypted" case .fragment: return "fragment" case .fileTransfer: return "fileTransfer" } } } // 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 // Verification (QR-based OOB binding) case verifyChallenge = 0x10 // Verification challenge case verifyResponse = 0x11 // Verification response var description: String { switch self { case .privateMessage: return "privateMessage" case .readReceipt: return "readReceipt" case .delivered: return "delivered" case .verifyChallenge: return "verifyChallenge" case .verifyResponse: return "verifyResponse" } } } // MARK: - Handshake State // Lazy handshake state tracking enum LazyHandshakeState { case none // No session, no handshake attempted case handshakeQueued // User action requires handshake case handshaking // Currently in handshake process case established // Session ready for use case failed(Error) // Handshake failed } // MARK: - Delivery Status // Delivery status for messages enum DeliveryStatus: Codable, Equatable, Hashable { case sending case sent // Left our device case delivered(to: String, at: Date) // Confirmed by recipient case read(by: String, at: Date) // Seen by recipient case failed(reason: String) case partiallyDelivered(reached: Int, total: Int) // For rooms var displayText: String { switch self { case .sending: return "Sending..." case .sent: return "Sent" case .delivered(let nickname, _): return "Delivered to \(nickname)" case .read(let nickname, _): return "Read by \(nickname)" case .failed(let reason): return "Failed: \(reason)" case .partiallyDelivered(let reached, let total): return "Delivered to \(reached)/\(total)" } } } // MARK: - Delegate Protocol protocol BitchatDelegate: AnyObject { func didReceiveMessage(_ message: BitchatMessage) func didConnectToPeer(_ peerID: PeerID) func didDisconnectFromPeer(_ peerID: PeerID) func didUpdatePeerList(_ peers: [PeerID]) // Optional method to check if a fingerprint belongs to a favorite peer func isFavorite(fingerprint: String) -> Bool func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) // Low-level events for better separation of concerns func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) // Bluetooth state updates for user notifications func didUpdateBluetoothState(_ state: CBManagerState) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) } // Provide default implementation to make it effectively optional extension BitchatDelegate { func isFavorite(fingerprint: String) -> Bool { return false } func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { // Default empty implementation } func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { // Default empty implementation } func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { // Default empty implementation } } ================================================ FILE: bitchat/Protocols/Geohash.swift ================================================ import Foundation /// Lightweight Geohash encoder used for Location Channels. /// Encodes latitude/longitude to base32 geohash with a fixed precision. enum Geohash { private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz") private static let base32Map: [Character: Int] = { var map: [Character: Int] = [:] for (i, c) in base32Chars.enumerated() { map[c] = i } return map }() /// Validates a geohash string for building-level precision (8 characters). /// - Parameter geohash: The geohash string to validate /// - Returns: true if valid 8-character base32 geohash, false otherwise static func isValidBuildingGeohash(_ geohash: String) -> Bool { guard geohash.count == 8 else { return false } return geohash.lowercased().allSatisfy { base32Map[$0] != nil } } /// Encodes the provided coordinates into a geohash string. /// - Parameters: /// - latitude: Latitude in degrees (-90...90) /// - longitude: Longitude in degrees (-180...180) /// - precision: Number of geohash characters (2-12 typical). Values <= 0 return an empty string. /// - Returns: Base32 geohash string of length `precision`. static func encode(latitude: Double, longitude: Double, precision: Int) -> String { guard precision > 0 else { return "" } var latInterval: (Double, Double) = (-90.0, 90.0) var lonInterval: (Double, Double) = (-180.0, 180.0) var isEven = true var bit = 0 var ch = 0 var geohash: [Character] = [] let lat = max(-90.0, min(90.0, latitude)) let lon = max(-180.0, min(180.0, longitude)) while geohash.count < precision { if isEven { let mid = (lonInterval.0 + lonInterval.1) / 2 if lon >= mid { ch |= (1 << (4 - bit)) lonInterval.0 = mid } else { lonInterval.1 = mid } } else { let mid = (latInterval.0 + latInterval.1) / 2 if lat >= mid { ch |= (1 << (4 - bit)) latInterval.0 = mid } else { latInterval.1 = mid } } isEven.toggle() if bit < 4 { bit += 1 } else { geohash.append(base32Chars[ch]) bit = 0 ch = 0 } } return String(geohash) } /// Decodes a geohash into the center latitude/longitude of its bounding box. /// - Parameter geohash: Base32 geohash string. /// - Returns: (lat, lon) center coordinate. static func decodeCenter(_ geohash: String) -> (lat: Double, lon: Double) { var latInterval: (Double, Double) = (-90.0, 90.0) var lonInterval: (Double, Double) = (-180.0, 180.0) var isEven = true for ch in geohash.lowercased() { guard let cd = base32Map[ch] else { continue } for mask in [16, 8, 4, 2, 1] { if isEven { let mid = (lonInterval.0 + lonInterval.1) / 2 if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid } } else { let mid = (latInterval.0 + latInterval.1) / 2 if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid } } isEven.toggle() } } let lat = (latInterval.0 + latInterval.1) / 2 let lon = (lonInterval.0 + lonInterval.1) / 2 return (lat, lon) } /// Decodes a geohash into its latitude and longitude bounds. /// - Parameter geohash: Base32 geohash string. /// - Returns: (latMin, latMax, lonMin, lonMax) static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) { var latInterval: (Double, Double) = (-90.0, 90.0) var lonInterval: (Double, Double) = (-180.0, 180.0) var isEven = true for ch in geohash.lowercased() { guard let cd = base32Map[ch] else { continue } for mask in [16, 8, 4, 2, 1] { if isEven { let mid = (lonInterval.0 + lonInterval.1) / 2 if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid } } else { let mid = (latInterval.0 + latInterval.1) / 2 if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid } } isEven.toggle() } } return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1) } /// Returns all 8 neighboring geohash cells at the same precision. /// - Parameter geohash: Base32 geohash string. /// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order). static func neighbors(of geohash: String) -> [String] { guard !geohash.isEmpty else { return [] } let precision = geohash.count let bounds = decodeBounds(geohash) let center = decodeCenter(geohash) // Calculate cell dimensions let latHeight = bounds.latMax - bounds.latMin let lonWidth = bounds.lonMax - bounds.lonMin // Helper to wrap longitude around ±180 func wrapLongitude(_ lon: Double) -> Double { var wrapped = lon while wrapped > 180.0 { wrapped -= 360.0 } while wrapped < -180.0 { wrapped += 360.0 } return wrapped } // Helper to clamp latitude to ±90 func clampLatitude(_ lat: Double) -> Double { return max(-90.0, min(90.0, lat)) } // Calculate 8 neighbor centers let neighbors: [(lat: Double, lon: Double)] = [ (center.lat + latHeight, center.lon), // N (center.lat + latHeight, center.lon + lonWidth), // NE (center.lat, center.lon + lonWidth), // E (center.lat - latHeight, center.lon + lonWidth), // SE (center.lat - latHeight, center.lon), // S (center.lat - latHeight, center.lon - lonWidth), // SW (center.lat, center.lon - lonWidth), // W (center.lat + latHeight, center.lon - lonWidth) // NW ] // Encode each neighbor, handling boundary conditions return neighbors.compactMap { neighbor in let lat = clampLatitude(neighbor.lat) let lon = wrapLongitude(neighbor.lon) // Skip if we've crossed a pole (latitude clamped to boundary) if (neighbor.lat > 90.0 || neighbor.lat < -90.0) { return nil } return encode(latitude: lat, longitude: lon, precision: precision) } } } ================================================ FILE: bitchat/Protocols/LocationChannel.swift ================================================ import Foundation /// Levels of location channels mapped to geohash precisions. enum GeohashChannelLevel: CaseIterable, Codable, Equatable { case building case block case neighborhood case city case province // previously .region case region // previously .country /// Geohash length used for this level. var precision: Int { switch self { case .building: return 8 case .block: return 7 case .neighborhood: return 6 case .city: return 5 case .province: return 4 case .region: return 2 } } var displayName: String { switch self { case .building: return String(localized: "location_levels.building", comment: "Name for building-level location channel") case .block: return String(localized: "location_levels.block", comment: "Name for block-level location channel") case .neighborhood: return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel") case .city: return String(localized: "location_levels.city", comment: "Name for city-level location channel") case .province: return String(localized: "location_levels.province", comment: "Name for province-level location channel") case .region: return String(localized: "location_levels.region", comment: "Name for region-level location channel") } } } // Backward-compatible Codable for renamed cases extension GeohashChannelLevel { init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let raw = try? container.decode(String.self) { switch raw { case "building": self = .building case "block": self = .block case "neighborhood": self = .neighborhood case "city": self = .city case "region": self = .province // old "region" maps to new .province case "country": self = .region // old "country" maps to new .region case "province": self = .province default: self = .block } } else if let precision = try? container.decode(Int.self) { switch precision { case 8: self = .building case 7: self = .block case 6: self = .neighborhood case 5: self = .city case 4: self = .province case 0...3: self = .region default: self = .block } } else { self = .block } } func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { case .building: try container.encode("building") case .block: try container.encode("block") case .neighborhood: try container.encode("neighborhood") case .city: try container.encode("city") case .province: try container.encode("province") case .region: try container.encode("region") } } } /// A computed geohash channel option. struct GeohashChannel: Codable, Equatable, Hashable, Identifiable { let level: GeohashChannelLevel let geohash: String var id: String { "\(level)-\(geohash)" } var displayName: String { "\(level.displayName) • \(geohash)" } } /// Identifier for current public chat channel (mesh or a location geohash). enum ChannelID: Equatable, Codable { case mesh case location(GeohashChannel) /// Human readable name for UI. var displayName: String { switch self { case .mesh: return "Mesh" case .location(let ch): return ch.displayName } } /// Nostr tag value for scoping (geohash), if applicable. var nostrGeohashTag: String? { switch self { case .mesh: return nil case .location(let ch): return ch.geohash } } var isMesh: Bool { switch self { case .mesh: true case .location: false } } var isLocation: Bool { switch self { case .mesh: false case .location: true } } } ================================================ FILE: bitchat/Protocols/Packets.swift ================================================ import Foundation // MARK: - Protocol TLV Packets struct AnnouncementPacket { let nickname: String let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement) let signingPublicKey: Data // Ed25519 public key for signing let directNeighbors: [Data]? // 8-byte peer IDs private enum TLVType: UInt8 { case nickname = 0x01 case noisePublicKey = 0x02 case signingPublicKey = 0x03 case directNeighbors = 0x04 } func encode() -> Data? { var data = Data() // Reserve: TLVs for nickname (2 + n), noise key (2 + 32), signing key (2 + 32) data.reserveCapacity(2 + min(nickname.count, 255) + 2 + noisePublicKey.count + 2 + signingPublicKey.count) // 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 noise public key guard noisePublicKey.count <= 255 else { return nil } data.append(TLVType.noisePublicKey.rawValue) data.append(UInt8(noisePublicKey.count)) data.append(noisePublicKey) // TLV for signing public key guard signingPublicKey.count <= 255 else { return nil } data.append(TLVType.signingPublicKey.rawValue) data.append(UInt8(signingPublicKey.count)) data.append(signingPublicKey) // TLV for direct neighbors (optional) if let neighbors = directNeighbors, !neighbors.isEmpty { let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 } if !neighborsData.isEmpty && neighborsData.count % 8 == 0 { data.append(TLVType.directNeighbors.rawValue) data.append(UInt8(neighborsData.count)) data.append(neighborsData) } } return data } static func decode(from data: Data) -> AnnouncementPacket? { var offset = 0 var nickname: String? var noisePublicKey: Data? var signingPublicKey: Data? var directNeighbors: [Data]? while offset + 2 <= data.count { let typeRaw = data[offset] offset += 1 let length = Int(data[offset]) offset += 1 guard offset + length <= data.count else { return nil } let value = data[offset.. 0 && length % 8 == 0 { var neighbors = [Data]() let count = length / 8 for i in 0.. Data? { var data = Data() data.reserveCapacity(2 + min(messageID.count, 255) + 2 + min(content.count, 255)) // 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.. (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": return false default: return true } } } ================================================ FILE: bitchat/Services/BLE/BLEService.swift ================================================ import BitLogger import Foundation import CoreBluetooth import Combine import CryptoKit #if os(iOS) import UIKit #endif /// BLEService — Bluetooth Mesh Transport /// - Emits events exclusively via `BitchatDelegate` for UI. /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). /// - A lightweight `peerSnapshotPublisher` is provided for non-UI services. final class BLEService: NSObject { // MARK: - Constants #if DEBUG static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A") // testnet #else static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet #endif static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") private static let centralRestorationID = "chat.bitchat.ble.central" private static let peripheralRestorationID = "chat.bitchat.ble.peripheral" // Default per-fragment chunk size when link limits are unknown private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize private let bleMaxMTU = 512 private let maxMessageLength = InputValidator.Limits.maxMessageLength private let messageTTL: UInt8 = TransportConfig.messageTTLDefault // Flood/battery controls private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays // MARK: - Core State (5 Essential Collections) // 1. Consolidated Peripheral Tracking private struct PeripheralState { let peripheral: CBPeripheral var characteristic: CBCharacteristic? var peerID: PeerID? var isConnecting: Bool = false var isConnected: Bool = false var lastConnectionAttempt: Date? = nil var assembler = NotificationStreamAssembler() } private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState private var peerToPeripheralUUID: [PeerID: String] = [:] // PeerID -> Peripheral UUID // 2. BLE Centrals (when acting as peripheral) private var subscribedCentrals: [CBCentral] = [] private var centralToPeerID: [String: PeerID] = [:] // Central UUID -> Peer ID mapping // BCH-01-004: Rate-limiting for subscription-triggered announces // Tracks subscription attempts per central to prevent enumeration attacks private struct SubscriptionRateLimitState { var lastAnnounceTime: Date var attemptCount: Int var currentBackoffSeconds: TimeInterval } private var centralSubscriptionRateLimits: [String: SubscriptionRateLimitState] = [:] // Central UUID -> rate limit state // 3. Peer Information (single source of truth) private struct PeerInfo { let peerID: PeerID var nickname: String var isConnected: Bool var noisePublicKey: Data? var signingPublicKey: Data? var isVerifiedNickname: Bool var lastSeen: Date } private var peers: [PeerID: PeerInfo] = [:] private var currentPeerIDs: [PeerID] { Array(peers.keys) } // 4. Efficient Message Deduplication private let messageDeduplicator = MessageDeduplicator() private var selfBroadcastMessageIDs: [String: (id: String, timestamp: Date)] = [:] private lazy var mediaDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" return formatter }() private let meshTopology = MeshTopologyTracker() // 5. Fragment Reassembly (necessary for messages > MTU) private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 } private var incomingFragments: [FragmentKey: [Int: Data]] = [:] private var fragmentMetadata: [FragmentKey: (type: UInt8, total: Int, timestamp: Date)] = [:] private struct ActiveTransferState { let totalFragments: Int var sentFragments: Int var workItems: [DispatchWorkItem] } private var activeTransfers: [String: ActiveTransferState] = [:] // Backoff for peripherals that recently timed out connecting private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout // Simple announce throttling private var lastAnnounceSent = Date.distantPast private let announceMinInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval // 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 private var noiseService: NoiseEncryptionService private let identityManager: SecureIdentityStateManagerProtocol private let keychain: KeychainManagerProtocol private let idBridge: NostrIdentityBridge private var myPeerIDData: Data = Data() // MARK: - Advertising Privacy // No Local Name by default for maximum privacy. No rotating alias. // 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) private let bleQueueKey = DispatchSpecificKey() // Queue for messages pending handshake completion private var pendingMessagesAfterHandshake: [PeerID: [(content: String, messageID: String)]] = [:] // Noise typed payloads (ACKs, read receipts, etc.) pending handshake private var pendingNoisePayloadsAfterHandshake: [PeerID: [Data]] = [:] // Queue for notifications that failed due to full queue private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = [] // Accumulate long write chunks per central until a full frame decodes private var pendingWriteBuffers: [String: Data] = [:] // Relay jitter scheduling to reduce redundant floods private var scheduledRelays: [String: DispatchWorkItem] = [:] // Track short-lived traffic bursts to adapt announces/scanning under load private var recentPacketTimestamps: [Date] = [] // Ingress link tracking for last-hop suppression private enum LinkID: Hashable { case peripheral(String) case central(String) } private var ingressByMessageID: [String: (link: LinkID, timestamp: Date)] = [:] // Backpressure-aware write queue per peripheral private struct OutboundPriority: Comparable { let level: Int let suborder: Int static let high = OutboundPriority(level: 0, suborder: 0) static func fragment(totalFragments: Int) -> OutboundPriority { OutboundPriority(level: 1, suborder: max(1, min(totalFragments, Int(UInt16.max)))) } static let fileTransfer = OutboundPriority(level: 2, suborder: Int.max - 1) static let low = OutboundPriority(level: 2, suborder: Int.max) static func < (lhs: OutboundPriority, rhs: OutboundPriority) -> Bool { if lhs.level != rhs.level { return lhs.level < rhs.level } return lhs.suborder < rhs.suborder } } private struct PendingWrite { let priority: OutboundPriority let data: Data } private struct PendingFragmentTransfer { let packet: BitchatPacket let pad: Bool let maxChunk: Int? let directedPeer: PeerID? let transferId: String? } private var pendingPeripheralWrites: [String: [PendingWrite]] = [:] private var pendingFragmentTransfers: [PendingFragmentTransfer] = [] // Debounce duplicate disconnect notifies private var recentDisconnectNotifies: [PeerID: Date] = [:] // Store-and-forward for directed messages when we have no links // Keyed by recipient short peerID -> messageID -> (packet, enqueuedAt) private var pendingDirectedRelays: [PeerID: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:] // Debounce for 'reconnected' logs private var lastReconnectLogAt: [PeerID: Date] = [:] // MARK: - Gossip Sync private var gossipSyncManager: GossipSyncManager? private let requestSyncManager = RequestSyncManager() // MARK: - Maintenance Timer private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks private var maintenanceCounter = 0 // Track maintenance cycles // MARK: - Connection budget & scheduling (central role) private let maxCentralLinks = TransportConfig.bleMaxCentralLinks private let connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval private var lastGlobalConnectAttempt: Date = .distantPast private struct ConnectionCandidate { let peripheral: CBPeripheral let rssi: Int let name: String let isConnectable: Bool let discoveredAt: Date } private var connectionCandidates: [ConnectionCandidate] = [] private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures private var lastIsolatedAt: Date? = nil private var dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault // MARK: - Adaptive scanning duty-cycle private var scanDutyTimer: DispatchSourceTimer? private var dutyEnabled: Bool = true private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration private var dutyActive: Bool = false // Debounced publish to coalesce rapid changes private var lastPeerPublishAt: Date = .distantPast private var peerPublishPending: Bool = false private let peerPublishMinInterval: TimeInterval = 0.1 private func requestPeerDataPublish() { let now = Date() let elapsed = now.timeIntervalSince(lastPeerPublishAt) if elapsed >= peerPublishMinInterval { lastPeerPublishAt = now publishFullPeerData() } else if !peerPublishPending { peerPublishPending = true let delay = peerPublishMinInterval - elapsed messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self else { return } self.lastPeerPublishAt = Date() self.peerPublishPending = false self.publishFullPeerData() } } } // MARK: - Initialization init( keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, initializeBluetoothManagers: Bool = true ) { self.keychain = keychain self.idBridge = idBridge noiseService = NoiseEncryptionService(keychain: keychain) self.identityManager = identityManager super.init() configureNoiseServiceCallbacks(for: noiseService) refreshPeerIdentity() // Set queue key for identification messageQueue.setSpecific(key: messageQueueKey, value: ()) // 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 // Tag BLE queue for re-entrancy detection bleQueue.setSpecific(key: bleQueueKey, value: ()) if initializeBluetoothManagers { // Initialize BLE on background queue to prevent main thread blocking. #if os(iOS) let centralOptions: [String: Any] = [ CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID ] centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions) let peripheralOptions: [String: Any] = [ CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID ] peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions) #else centralManager = CBCentralManager(delegate: self, queue: bleQueue) peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) #endif } // Single maintenance timer for all periodic tasks (dispatch-based for determinism) let timer = DispatchSource.makeTimerSource(queue: bleQueue) timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval, repeating: TransportConfig.bleMaintenanceInterval, leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds)) timer.setEventHandler { [weak self] in self?.performMaintenance() } timer.resume() maintenanceTimer = timer // Publish initial empty state requestPeerDataPublish() // Initialize gossip sync manager restartGossipManager() } private func restartGossipManager() { // Stop existing gossipSyncManager?.stop() let config = GossipSyncManager.Config( seenCapacity: TransportConfig.syncSeenCapacity, gcsMaxBytes: TransportConfig.syncGCSMaxBytes, gcsTargetFpr: TransportConfig.syncGCSTargetFpr, maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds, maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds, stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds, stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds, fragmentCapacity: TransportConfig.syncFragmentCapacity, fileTransferCapacity: TransportConfig.syncFileTransferCapacity, fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds, fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds, messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds ) let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) manager.delegate = self manager.start() gossipSyncManager = manager } // No advertising policy to set; we never include Local Name in adverts. deinit { maintenanceTimer?.cancel() scanDutyTimer?.cancel() scanDutyTimer = nil centralManager?.stopScan() peripheralManager?.stopAdvertising() #if os(iOS) NotificationCenter.default.removeObserver(self) #endif } func resetIdentityForPanic(currentNickname: String) { messageQueue.sync(flags: .barrier) { pendingMessagesAfterHandshake.removeAll() pendingNoisePayloadsAfterHandshake.removeAll() } collectionsQueue.sync(flags: .barrier) { pendingPeripheralWrites.removeAll() pendingFragmentTransfers.removeAll() pendingNotifications.removeAll() pendingDirectedRelays.removeAll() ingressByMessageID.removeAll() recentPacketTimestamps.removeAll() scheduledRelays.values.forEach { $0.cancel() } scheduledRelays.removeAll() } bleQueue.sync { pendingWriteBuffers.removeAll() recentConnectTimeouts.removeAll() } recentDisconnectNotifies.removeAll() noiseService.clearEphemeralStateForPanic() noiseService.clearPersistentIdentity() let newNoise = NoiseEncryptionService(keychain: keychain) noiseService = newNoise configureNoiseServiceCallbacks(for: newNoise) refreshPeerIdentity() restartGossipManager() setNickname(currentNickname) messageDeduplicator.reset() messageQueue.async(flags: .barrier) { [weak self] in self?.selfBroadcastMessageIDs.removeAll() } requestPeerDataPublish() startServices() } // Ensure this runs on message queue to avoid main thread blocking func sendMessage(_ content: String, mentions: [String] = [], to recipientID: PeerID? = nil, messageID: String? = nil, timestamp: Date? = nil) { // Call directly if already on messageQueue, otherwise dispatch if DispatchQueue.getSpecific(key: messageQueueKey) == nil { messageQueue.async { [weak self] in self?.sendMessage(content, mentions: mentions, to: recipientID, messageID: messageID, timestamp: timestamp) } return } guard content.count <= maxMessageLength else { SecureLogger.error("Message too long: \(content.count) chars", category: .session) return } if let recipientID { sendPrivateMessage(content, to: recipientID, messageID: messageID ?? UUID().uuidString) return } // Public broadcast // Create packet with explicit fields so we can sign it let sendDate = timestamp ?? Date() let sendTimestampMs = UInt64(sendDate.timeIntervalSince1970 * 1000) let basePacket = BitchatPacket( type: MessageType.message.rawValue, senderID: Data(hexString: myPeerID.id) ?? Data(), recipientID: nil, timestamp: sendTimestampMs, payload: Data(content.utf8), signature: nil, ttl: messageTTL ) guard let signedPacket = noiseService.signPacket(basePacket) else { SecureLogger.error("❌ Failed to sign public message", category: .security) return } // Pre-mark our own broadcast as processed to avoid handling relayed self copy let senderHex = signedPacket.senderID.hexEncodedString() let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)" messageDeduplicator.markProcessed(dedupID) if let messageID { selfBroadcastMessageIDs[dedupID] = (id: messageID, timestamp: sendDate) } // Call synchronously since we're already on background queue broadcastPacket(signedPacket) // Track our own broadcast for sync gossipSyncManager?.onPublicPacketSeen(signedPacket) } // MARK: - Transport Protocol Conformance // MARK: Delegates weak var delegate: BitchatDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? // MARK: Peer snapshots publisher (non-UI convenience) private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>() var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { peerSnapshotSubject.eraseToAnyPublisher() } func currentPeerSnapshots() -> [TransportPeerSnapshot] { collectionsQueue.sync { let snapshot = Array(peers.values) let resolvedNames = PeerDisplayNameResolver.resolve( snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) }, selfNickname: myNickname ) return snapshot.map { info in TransportPeerSnapshot( peerID: info.peerID, nickname: resolvedNames[info.peerID] ?? info.nickname, isConnected: info.isConnected, noisePublicKey: info.noisePublicKey, lastSeen: info.lastSeen ) } } } // MARK: Identity var myPeerID = PeerID(str: "") var myNickname: String = "anon" func setNickname(_ nickname: String) { self.myNickname = nickname // Send announce to notify peers of nickname change (force send) sendAnnounce(forceSend: true) } // MARK: Lifecycle func startServices() { // Start BLE services if not already running if centralManager?.state == .poweredOn { centralManager?.scanForPeripherals( withServices: [BLEService.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] ) } // Send initial announce after services are ready // Use longer delay to avoid conflicts with other announces messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in self?.sendAnnounce(forceSend: true) } } func stopServices() { // Send leave message synchronously to ensure delivery let leavePacket = BitchatPacket( type: MessageType.leave.rawValue, senderID: myPeerIDData, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: Data(), signature: nil, ttl: messageTTL ) // Send immediately to all connected peers (synchronized access to BLE state) if let data = leavePacket.toBinaryData(padding: false) { let leavePriority = priority(for: leavePacket, data: data) // Snapshot BLE state under bleQueue to avoid races with delegate callbacks let (peripheralStates, centralsCount, char) = bleQueue.sync { (Array(peripherals.values), subscribedCentrals.count, characteristic) } // Send to peripherals we're connected to as central for state in peripheralStates where state.isConnected { if let characteristic = state.characteristic { writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: leavePriority) } } // Send to centrals subscribed to us as peripheral if centralsCount > 0, let ch = char { peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil) } } // Give leave message a moment to send (cooperative delay allows BLE callbacks to fire) let deadline = Date().addingTimeInterval(TransportConfig.bleThreadSleepWriteShortDelaySeconds) while Date() < deadline { RunLoop.current.run(until: Date().addingTimeInterval(0.01)) } // Clear pending notifications collectionsQueue.sync(flags: .barrier) { pendingNotifications.removeAll() } // Stop timer maintenanceTimer?.cancel() maintenanceTimer = nil scanDutyTimer?.cancel() scanDutyTimer = nil centralManager?.stopScan() peripheralManager?.stopAdvertising() // Disconnect all peripherals (synchronized access) let peripheralsToDisconnect = bleQueue.sync { Array(peripherals.values) } for state in peripheralsToDisconnect { centralManager?.cancelPeripheralConnection(state.peripheral) } } func emergencyDisconnectAll() { stopServices() // Clear all sessions and peers let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) { let entries = activeTransfers.map { ($0.key, $0.value.workItems) } peers.removeAll() incomingFragments.removeAll() fragmentMetadata.removeAll() activeTransfers.removeAll() // Also clear pending message queues to avoid stale state across sessions pendingMessagesAfterHandshake.removeAll() pendingNoisePayloadsAfterHandshake.removeAll() pendingDirectedRelays.removeAll() return entries } for entry in cancelledTransfers { entry.items.forEach { $0.cancel() } TransferProgressManager.shared.cancel(id: entry.id) } // Clear processed messages messageDeduplicator.reset() // Clear peripheral references (synchronized access to avoid races with BLE callbacks) bleQueue.sync { peripherals.removeAll() peerToPeripheralUUID.removeAll() subscribedCentrals.removeAll() centralToPeerID.removeAll() centralSubscriptionRateLimits.removeAll() } meshTopology.reset() } // MARK: Connectivity and peers func isPeerConnected(_ peerID: PeerID) -> Bool { // Accept both 16-hex short IDs and 64-hex Noise keys let shortID = peerID.toShort() return collectionsQueue.sync { peers[shortID]?.isConnected ?? false } } func isPeerReachable(_ peerID: PeerID) -> Bool { // Accept both 16-hex short IDs and 64-hex Noise keys let shortID = peerID.toShort() return collectionsQueue.sync { // Must be mesh-attached: at least one live direct link to the mesh let meshAttached = peers.values.contains { $0.isConnected } guard let info = peers[shortID] else { return false } if info.isConnected { return true } guard meshAttached else { return false } // Apply reachability retention window let isVerified = info.isVerifiedNickname let retention: TimeInterval = isVerified ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds return Date().timeIntervalSince(info.lastSeen) <= retention } } func peerNickname(peerID: PeerID) -> String? { collectionsQueue.sync { guard let peer = peers[peerID], peer.isConnected else { return nil } return peer.nickname } } func getPeerNicknames() -> [PeerID: String] { return collectionsQueue.sync { let connected = peers.filter { $0.value.isConnected } let tuples = connected.map { ($0.key, $0.value.nickname, true) } return PeerDisplayNameResolver.resolve(tuples, selfNickname: myNickname) } } // MARK: Protocol utilities func getFingerprint(for peerID: PeerID) -> String? { return collectionsQueue.sync { return peers[peerID]?.noisePublicKey?.sha256Fingerprint() } } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { if noiseService.hasEstablishedSession(with: peerID) { return .established } else if noiseService.hasSession(with: peerID) { return .handshaking } else { return .none } } func triggerHandshake(with peerID: PeerID) { initiateNoiseHandshake(with: peerID) } func getNoiseService() -> NoiseEncryptionService { return noiseService } func getCurrentBluetoothState() -> CBManagerState { return centralManager?.state ?? .unknown } // MARK: Messaging func cancelTransfer(_ transferId: String) { collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } if let state = self.activeTransfers.removeValue(forKey: transferId) { state.workItems.forEach { $0.cancel() } TransferProgressManager.shared.cancel(id: transferId) SecureLogger.debug("🛑 Cancelled transfer \(transferId.prefix(8))…", category: .session) self.messageQueue.async { [weak self] in self?.startNextPendingTransferIfNeeded() } } else if let pendingIndex = self.pendingFragmentTransfers.firstIndex(where: { $0.transferId == transferId }) { self.pendingFragmentTransfers.remove(at: pendingIndex) TransferProgressManager.shared.cancel(id: transferId) SecureLogger.debug("🛑 Removed pending transfer \(transferId.prefix(8))… before start", category: .session) } } } // Transport protocol conformance helper: simplified public message send func sendMessage(_ content: String, mentions: [String]) { // Delegate to the full API with default routing sendMessage(content, mentions: mentions, to: nil, messageID: nil, timestamp: nil) } func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { sendMessage(content, mentions: mentions, to: nil, messageID: messageID, timestamp: timestamp) } func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { sendPrivateMessage(content, to: peerID, messageID: messageID) } func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) { messageQueue.async { [weak self] in guard let self = self else { return } guard let payload = filePacket.encode() else { SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session) return } let packet = BitchatPacket( type: MessageType.fileTransfer.rawValue, senderID: self.myPeerIDData, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: self.messageTTL, version: 2 ) let senderHex = packet.senderID.hexEncodedString() let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)" self.messageDeduplicator.markProcessed(dedupID) SecureLogger.debug("📁 Broadcasting file transfer payload bytes=\(payload.count)", category: .session) self.broadcastPacket(packet, transferId: transferId) self.gossipSyncManager?.onPublicPacketSeen(packet) } } func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) { messageQueue.async { [weak self] in guard let self = self else { return } guard let payload = filePacket.encode() else { SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) return } // Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility // This ensures 64-hex Noise keys are converted to the canonical routing format let targetID = peerID.toShort() guard let recipientData = Data(hexString: targetID.id) else { SecureLogger.error("❌ Invalid recipient peer ID for file transfer: \(peerID)", category: .session) return } var packet = BitchatPacket( type: MessageType.fileTransfer.rawValue, senderID: self.myPeerIDData, recipientID: recipientData, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: self.messageTTL, version: 2 ) if let signed = self.noiseService.signPacket(packet) { packet = signed } SecureLogger.debug("📁 Sending private file transfer to \(peerID.id.prefix(8))… bytes=\(payload.count)", category: .session) self.broadcastPacket(packet, transferId: transferId) } } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { // Create typed payload: [type byte] + [message ID] var payload = Data([NoisePayloadType.readReceipt.rawValue]) payload.append(contentsOf: receipt.originalMessageID.utf8) if noiseService.hasEstablishedSession(with: peerID) { SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session) do { let encrypted = try noiseService.encrypt(payload, for: peerID) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, signature: nil, ttl: messageTTL ) broadcastPacket(packet) } catch { SecureLogger.error("Failed to send read receipt: \(error)") } } else { // Queue for after handshake and initiate if needed collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) } if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session) } } private enum ConnectionSource { case peripheral(String) case central(String) case unknown } private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID, connectionSource: ConnectionSource = .unknown) -> Bool { let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) let isRSR = packet.isRSR var skipTimestampCheck = false if isRSR { if requestSyncManager.isValidResponse(from: peerID, isRSR: true) { SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security) skipTimestampCheck = true } else { SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security) return false } } if !skipTimestampCheck { let maxSkew: UInt64 = 120_000 let packetTime = packet.timestamp let skew = (packetTime > currentTime) ? (packetTime - currentTime) : (currentTime - packetTime) if skew > maxSkew { SecureLogger.warning("Packet timestamp skewed by \(skew)ms (max \(maxSkew)ms) from \(peerID.id.prefix(8))…", category: .security) return false } } return true } // MARK: - Packet Broadcasting private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) { // Apply route if recipient exists (centralized route application) let packetToSend: BitchatPacket if let recipientPeerID = PeerID(hexData: packet.recipientID) { packetToSend = applyRouteIfAvailable(packet, to: recipientPeerID) } else { packetToSend = packet } // Encode once using a small per-type padding policy, then delegate by type let padForBLE = padPolicy(for: packetToSend.type) if packetToSend.type == MessageType.fileTransfer.rawValue { sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId) return } guard let data = packetToSend.toBinaryData(padding: padForBLE) else { SecureLogger.error("❌ Failed to convert packet to binary data", category: .session) return } if packetToSend.type == MessageType.noiseEncrypted.rawValue { sendEncrypted(packetToSend, data: data, pad: padForBLE) return } sendGenericBroadcast(packetToSend, data: data, pad: padForBLE) } // MARK: - Broadcast helpers (single responsibility) private func padPolicy(for type: UInt8) -> Bool { switch MessageType(rawValue: type) { case .noiseEncrypted, .noiseHandshake: return true case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer: return false } } private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) { guard let recipientPeerID = PeerID(hexData: packet.recipientID) else { return } var sentEncrypted = false let outboundPriority = priority(for: packet, data: data) // Per-link limits for the specific peer var peripheralMaxLen: Int? if let perUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }) { if let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[perUUID] : bleQueue.sync(execute: { peripherals[perUUID] }) { peripheralMaxLen = state.peripheral.maximumWriteValueLength(for: .withoutResponse) } } var centralMaxLen: Int? do { let (centrals, mapping) = snapshotSubscribedCentrals() if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == recipientPeerID }) { centralMaxLen = central.maximumUpdateValueLength } } if let pm = peripheralMaxLen, data.count > pm { let overhead = 13 + 8 + 8 + 13 let chunk = max(64, pm - overhead) sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID) return } if let cm = centralMaxLen, data.count > cm { let overhead = 13 + 8 + 8 + 13 let chunk = max(64, cm - overhead) sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID) return } // Direct write via peripheral link if let peripheralUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }), let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }), state.isConnected, let characteristic = state.characteristic { writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: outboundPriority) sentEncrypted = true } // Notify via central link (dual-role) if let characteristic = characteristic, !sentEncrypted { let (centrals, mapping) = snapshotSubscribedCentrals() for central in centrals where mapping[central.identifier.uuidString] == recipientPeerID { let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false if success { sentEncrypted = true; break } enqueuePendingNotification(data: data, centrals: [central], context: "encrypted") } } if !sentEncrypted { // Flood as last resort with recipient set; link aware sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: recipientPeerID) } } private func sendGenericBroadcast(_ packet: BitchatPacket, data: Data, pad: Bool) { sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: nil) } private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) { collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount { self.pendingNotifications.append((data: data, centrals: centrals)) SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(self.pendingNotifications.count))", category: .session) return } if attempt >= TransportConfig.bleNotificationRetryMaxAttempts { SecureLogger.error("❌ Dropping \(context) packet after exhausting retry window (pending=\(self.pendingNotifications.count))", category: .session) return } let backoff = TransportConfig.bleNotificationRetryDelayMs * max(1, attempt + 1) let deadline = DispatchTime.now() + .milliseconds(backoff) self.messageQueue.asyncAfter(deadline: deadline) { [weak self] in self?.enqueuePendingNotification(data: data, centrals: centrals, context: context, attempt: attempt + 1) } } } private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) { // Determine last-hop link for this message to avoid echoing back let messageID = makeMessageID(for: packet) let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link } let directedPeerHint: PeerID? = { if let explicit = directedOnlyPeer { return explicit } if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty { return recipient } return nil }() let outboundPriority = priority(for: packet, data: data) let states = snapshotPeripheralStates() var minCentralWriteLen: Int? for s in states where s.isConnected { let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse) minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m } var snapshotCentrals: [CBCentral] = [] if let _ = characteristic { let (centrals, _) = snapshotSubscribedCentrals() snapshotCentrals = centrals } var minNotifyLen: Int? if !snapshotCentrals.isEmpty { minNotifyLen = snapshotCentrals.map { $0.maximumUpdateValueLength }.min() } // Avoid re-fragmenting fragment packets if packet.type != MessageType.fragment.rawValue, let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min(), data.count > minLen { let overhead = 13 + 8 + 8 + 13 let chunk = max(64, minLen - overhead) sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer) return } // Build link lists and apply K-of-N fanout for broadcasts; always exclude ingress link let connectedPeripheralIDs: [String] = states.filter { $0.isConnected }.map { $0.peripheral.identifier.uuidString } let subscribedCentrals: [CBCentral] var centralIDs: [String] = [] if let _ = characteristic { let (centrals, _) = snapshotSubscribedCentrals() subscribedCentrals = centrals centralIDs = centrals.map { $0.identifier.uuidString } } else { subscribedCentrals = [] } // Exclude ingress link var allowedPeripheralIDs = connectedPeripheralIDs var allowedCentralIDs = centralIDs if let ingress = ingressLink { switch ingress { case .peripheral(let id): allowedPeripheralIDs.removeAll { $0 == id } case .central(let id): allowedCentralIDs.removeAll { $0 == id } } } // For broadcast (no directed peer) and non-fragment, choose a subset deterministically // Special-case control/presence messages: do NOT subset to maximize immediate coverage var selectedPeripheralIDs = Set(allowedPeripheralIDs) var selectedCentralIDs = Set(allowedCentralIDs) if directedPeerHint == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue && packet.type != MessageType.requestSync.rawValue { let kp = subsetSizeForFanout(allowedPeripheralIDs.count) let kc = subsetSizeForFanout(allowedCentralIDs.count) selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID) selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID) } // If directed and we currently have no links to forward on, spool for a short window if let only = directedPeerHint, selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty, (packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) { spoolDirectedPacket(packet, recipientPeerID: only) } // Writes to selected connected peripherals for s in states where s.isConnected { let pid = s.peripheral.identifier.uuidString guard selectedPeripheralIDs.contains(pid) else { continue } if let ch = s.characteristic { writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority) } } // Notify selected subscribed centrals if let ch = characteristic { let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) } if !targets.isEmpty { let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false if !success { // Notification queue full - queue for retry to prevent silent packet loss // This is critical for fragment delivery reliability let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast" enqueuePendingNotification(data: data, centrals: targets, context: context) } } } } // Directed send helper (unicast to a specific peerID) without altering packet contents private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) { guard let data = packet.toBinaryData(padding: false) else { return } sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID) } // MARK: - Directed store-and-forward private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: PeerID) { let msgID = makeMessageID(for: packet) collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:] if byMsg[msgID] == nil { byMsg[msgID] = (packet: packet, enqueuedAt: Date()) self.pendingDirectedRelays[recipientPeerID] = byMsg SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: .session) } } } private func flushDirectedSpool() { // Move items out and attempt broadcast; if still no links, they'll be re-spooled let toSend: [(String, BitchatPacket)] = collectionsQueue.sync(flags: .barrier) { var out: [(String, BitchatPacket)] = [] let now = Date() for (recipient, dict) in pendingDirectedRelays { for (_, entry) in dict { if now.timeIntervalSince(entry.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds { out.append((recipient.id, entry.packet)) } } // Clear recipient bucket; items will be re-spooled if still no links pendingDirectedRelays.removeValue(forKey: recipient) } return out } guard !toSend.isEmpty else { return } for (_, packet) in toSend { messageQueue.async { [weak self] in self?.broadcastPacket(packet) } } } private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) { if peerID == myPeerID && packet.ttl != 0 { return } var accepted = false var senderNickname = "" let peersSnapshot = collectionsQueue.sync { peers } if peerID == myPeerID { accepted = true senderNickname = myNickname } else if let info = peersSnapshot[peerID], info.isVerifiedNickname { accepted = true senderNickname = info.nickname let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname) if hasCollision { senderNickname += "#" + String(peerID.id.prefix(4)) } } else if let info = peersSnapshot[peerID], info.isConnected { accepted = true senderNickname = info.nickname.isEmpty ? "anon" + String(peerID.id.prefix(4)) : info.nickname let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname) if hasCollision { senderNickname += "#" + String(peerID.id.prefix(4)) } } else if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) for candidate in candidates { if let signingKey = candidate.signingPublicKey, noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { accepted = true if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) { senderNickname = social.localPetname ?? social.claimedNickname } else { senderNickname = "anon" + String(peerID.id.prefix(4)) } break } } } guard accepted else { SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) return } // Skip directed packets that are not intended for us if let recipient = packet.recipientID { if PeerID(hexData: recipient) != myPeerID && !recipient.allSatisfy({ $0 == 0xFF }) { return } } if let recipient = packet.recipientID, recipient.allSatisfy({ $0 == 0xFF }) { gossipSyncManager?.onPublicPacketSeen(packet) } else if packet.recipientID == nil { gossipSyncManager?.onPublicPacketSeen(packet) } guard let filePacket = BitchatFilePacket.decode(packet.payload) else { SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) return } guard FileTransferLimits.isValidPayload(filePacket.content.count) else { SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(filePacket.content.count) bytes)", category: .security) return } guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else { SecureLogger.warning("🚫 MIME REJECT: '\(filePacket.mimeType ?? "")' not supported. Size=\(filePacket.content.count)b from \(peerID.id.prefix(8))...", category: .security) return } // Validate content matches declared MIME type (magic byte check) guard mime.matches(data: filePacket.content) else { let prefix = filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ") SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(filePacket.content.count)b prefix=[\(prefix)] from \(peerID.id.prefix(8))...", category: .security) return } // BCH-01-002: Enforce storage quota before saving enforceIncomingFilesQuota(reservingBytes: filePacket.content.count) let fallbackExt = mime.defaultExtension let subdirectory: String switch mime.category { case .audio: subdirectory = "voicenotes/incoming" case .image: subdirectory = "images/incoming" case .file: subdirectory = "files/incoming" } guard let destination = saveIncomingFile( data: filePacket.content, preferredName: filePacket.fileName, subdirectory: subdirectory, fallbackExtension: fallbackExt, defaultPrefix: mime.category.rawValue ) else { return } let marker: String let fileName = destination.lastPathComponent switch mime.category { case .audio: marker = "[voice] \(fileName)" case .image: marker = "[image] \(fileName)" case .file: marker = "[file] \(fileName)" } let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID if isPrivateMessage { updatePeerLastSeen(peerID) } let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let message = BitchatMessage( sender: senderNickname, content: marker, timestamp: ts, isRelay: false, originalSender: nil, isPrivate: isPrivateMessage, recipientNickname: nil, senderPeerID: peerID ) SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) notifyUI { [weak self] in self?.delegate?.didReceiveMessage(message) } } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { SecureLogger.debug("🔔 sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)", category: .session) // Include Nostr public key in the notification var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]" // Add our Nostr public key if available if let myNostrIdentity = try? idBridge.getCurrentNostrIdentity() { content += ":" + myNostrIdentity.npub SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session) } SecureLogger.debug("📤 Sending favorite notification to \(peerID): \(content)", category: .session) sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString) } func sendBroadcastAnnounce() { sendAnnounce() } func sendDeliveryAck(for messageID: String, to peerID: PeerID) { // Create typed payload: [type byte] + [message ID] var payload = Data([NoisePayloadType.delivered.rawValue]) payload.append(contentsOf: messageID.utf8) if noiseService.hasEstablishedSession(with: peerID) { do { let encrypted = try noiseService.encrypt(payload, for: peerID) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, signature: nil, ttl: messageTTL ) broadcastPacket(packet) } catch { SecureLogger.error("Failed to send delivery ACK: \(error)") } } else { // Queue for after handshake and initiate if needed collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) } if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", category: .session) } } private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) { _ = collectionsQueue.sync(flags: .barrier) { // Remove the peer when they leave peers.removeValue(forKey: peerID) } // Remove any stored announcement for sync purposes gossipSyncManager?.removeAnnouncementForPeer(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.delegate?.didDisconnectFromPeer(peerID) self.delegate?.didUpdatePeerList(currentPeerIDs) } } // MARK: - Helper Functions private func applicationFilesDirectory() throws -> URL { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesDir = base.appendingPathComponent("files", isDirectory: true) try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) return filesDir } private func sanitizeFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String { var candidate = name ?? "" // Security: Remove null bytes (path traversal vector) candidate = candidate.replacingOccurrences(of: "\0", with: "") // Security: Unicode normalization prevents fullwidth character bypass candidate = candidate.precomposedStringWithCanonicalMapping // Security: Remove ALL path separators (not just strip last component) candidate = candidate.replacingOccurrences(of: "/", with: "_") candidate = candidate.replacingOccurrences(of: "\\", with: "_") // Security: Remove control characters and dangerous filesystem chars let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters) candidate = candidate.components(separatedBy: invalid).joined(separator: "_") candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines) if candidate.isEmpty { candidate = defaultName } // Security: Reject dotfiles (hidden file attacks) if candidate.hasPrefix(".") { candidate = "_" + candidate } // Truncate while preserving extension if candidate.count > 120 { let ext = (candidate as NSString).pathExtension let base = (candidate as NSString).deletingPathExtension if ext.isEmpty { candidate = String(candidate.prefix(120)) } else { let maxBase = max(10, 120 - ext.count - 1) candidate = String(base.prefix(maxBase)) + "." + ext } } if let fallbackExtension = fallbackExtension, (candidate as NSString).pathExtension.isEmpty { candidate += ".\(fallbackExtension)" } if candidate.isEmpty { candidate = defaultName } return candidate } private func uniqueFileURL(in directory: URL, fileName: String) -> URL { var candidate = directory.appendingPathComponent(fileName) // Security: Validate path doesn't escape directory if !candidate.path.hasPrefix(directory.path) { SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security) return directory.appendingPathComponent("blocked_\(UUID().uuidString)") } if !FileManager.default.fileExists(atPath: candidate.path) { return candidate } let baseName = (fileName as NSString).deletingPathExtension let ext = (fileName as NSString).pathExtension var counter = 1 // Limit iterations to prevent DoS while counter < 100 { let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)" candidate = directory.appendingPathComponent(newName) // Validate each iteration guard candidate.path.hasPrefix(directory.path) else { return directory.appendingPathComponent("blocked_\(UUID().uuidString)") } if !FileManager.default.fileExists(atPath: candidate.path) { return candidate } counter += 1 } // Fallback: UUID to guarantee uniqueness return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)") } private func saveIncomingFile(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String) -> URL? { do { let base = try applicationFilesDirectory().appendingPathComponent(subdirectory, isDirectory: true) try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil) let timestamp = mediaDateFormatter.string(from: Date()) let defaultName = "\(defaultPrefix)_\(timestamp)" let sanitized = sanitizeFileName(preferredName, defaultName: defaultName, fallbackExtension: fallbackExtension) let destination = uniqueFileURL(in: base, fileName: sanitized) try data.write(to: destination, options: .atomic) return destination } catch { SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session) return nil } } // MARK: - Storage Quota Management (BCH-01-002) /// Maximum total storage for incoming files (100 MB) private static let incomingFilesQuota: Int64 = 100 * 1024 * 1024 /// Enforces storage quota for incoming files by deleting oldest files when quota is exceeded. /// Call before saving a new incoming file. private func enforceIncomingFilesQuota(reservingBytes: Int) { do { let base = try applicationFilesDirectory() let incomingDirs = [ base.appendingPathComponent("voicenotes/incoming", isDirectory: true), base.appendingPathComponent("images/incoming", isDirectory: true), base.appendingPathComponent("files/incoming", isDirectory: true) ] // Gather all incoming files with their sizes and modification dates var allFiles: [(url: URL, size: Int64, modified: Date)] = [] let fileManager = FileManager.default for dir in incomingDirs { guard fileManager.fileExists(atPath: dir.path) else { continue } guard let contents = try? fileManager.contentsOfDirectory( at: dir, includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey], options: [.skipsHiddenFiles] ) else { continue } for fileURL in contents { guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]), let size = attrs.fileSize, let modified = attrs.contentModificationDate else { continue } allFiles.append((url: fileURL, size: Int64(size), modified: modified)) } } // Calculate current usage let currentUsage = allFiles.reduce(0) { $0 + $1.size } let targetUsage = Self.incomingFilesQuota - Int64(reservingBytes) guard currentUsage > targetUsage else { return } // Sort by modification date (oldest first) and delete until under quota let sortedFiles = allFiles.sorted { $0.modified < $1.modified } var freedSpace: Int64 = 0 let needToFree = currentUsage - targetUsage for file in sortedFiles { guard freedSpace < needToFree else { break } do { try fileManager.removeItem(at: file.url) freedSpace += file.size SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security) } catch { SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security) } } if freedSpace > 0 { SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security) } } catch { SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security) } } 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 ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval if timeSinceLastAnnounce < minInterval { // Skipping announce (rate limited) return } lastAnnounceSent = now // Reduced logging - only log errors, not every announce // Create announce payload with both noise and signing public keys let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification let connectedPeerIDs: [Data] = collectionsQueue.sync { peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData } } let announcement = AnnouncementPacket( nickname: myNickname, noisePublicKey: noisePub, signingPublicKey: signingPub, directNeighbors: connectedPeerIDs ) guard let payload = announcement.encode() else { SecureLogger.error("❌ Failed to encode announce packet", category: .session) return } // Create packet with signature using the noise private key let packet = BitchatPacket( type: MessageType.announce.rawValue, senderID: myPeerIDData, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, // Will be set by signPacket below ttl: messageTTL ) // Sign the packet using the noise private key guard let signedPacket = noiseService.signPacket(packet) else { SecureLogger.error("❌ Failed to sign announce packet", category: .security) return } // Call directly if on messageQueue, otherwise dispatch if DispatchQueue.getSpecific(key: messageQueueKey) != nil { broadcastPacket(signedPacket) } else { messageQueue.async { [weak self] in self?.broadcastPacket(signedPacket) } } // Ensure our own announce is included in sync state gossipSyncManager?.onPublicPacketSeen(signedPacket) } // MARK: QR Verification over Noise func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA) sendNoisePayload(payload, to: peerID) } func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return } sendNoisePayload(payload, to: peerID) } } // MARK: - GossipSyncManager Delegate extension BLEService: GossipSyncManager.Delegate { func sendPacket(_ packet: BitchatPacket) { broadcastPacket(packet) } func sendPacket(to peerID: PeerID, packet: BitchatPacket) { sendPacketDirected(packet, to: peerID) } func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket { return noiseService.signPacket(packet) ?? packet } func getConnectedPeers() -> [PeerID] { return collectionsQueue.sync { peers.values.compactMap { $0.isConnected ? $0.peerID : nil } } } } // MARK: - CBCentralManagerDelegate extension BLEService: CBCentralManagerDelegate { #if os(iOS) func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? [] let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? [] let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:] let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool SecureLogger.info( "♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))", category: .session ) for peripheral in restoredPeripherals { let identifier = peripheral.identifier.uuidString peripheral.delegate = self let existing = peripherals[identifier] let assembler = existing?.assembler ?? NotificationStreamAssembler() let characteristic = existing?.characteristic let peerID = existing?.peerID let wasConnecting = existing?.isConnecting ?? false let wasConnected = existing?.isConnected ?? false let restoredState = PeripheralState( peripheral: peripheral, characteristic: characteristic, peerID: peerID, isConnecting: wasConnecting || peripheral.state == .connecting, isConnected: wasConnected || peripheral.state == .connected, lastConnectionAttempt: existing?.lastConnectionAttempt, assembler: assembler ) peripherals[identifier] = restoredState } captureBluetoothStatus(context: "central-restore") if central.state == .poweredOn { startScanning() } } #endif func centralManagerDidUpdateState(_ central: CBCentralManager) { // Notify delegate about state change on main thread Task { @MainActor in self.delegate?.didUpdateBluetoothState(central.state) } switch central.state { case .poweredOn: // Start scanning - use allow duplicates for faster discovery when active startScanning() case .poweredOff: // Bluetooth was turned off - stop scanning and clean up connection state SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session) central.stopScan() // Mark all peripheral connections as disconnected (they are now invalid) let peerIDs: [PeerID] = peripherals.compactMap { $0.value.peerID } for state in peripherals.values { central.cancelPeripheralConnection(state.peripheral) } peripherals.removeAll() peerToPeripheralUUID.removeAll() // Notify UI of disconnections for peerID in peerIDs { notifyUI { [weak self] in self?.notifyPeerDisconnectedDebounced(peerID) } } case .unauthorized: // User denied Bluetooth permission SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session) central.stopScan() peripherals.removeAll() peerToPeripheralUUID.removeAll() case .unsupported: // Device doesn't support BLE SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session) case .resetting: // Bluetooth stack is resetting - will get another state update when done SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session) case .unknown: // Initial state before we know the actual state SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session) @unknown default: SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session) } } 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: [BLEService.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 ?? (peripheralID.prefix(6) + "…") let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true let rssiValue = RSSI.intValue // Skip if peripheral is not connectable (per advertisement data) guard isConnectable else { return } // Skip immediate connect if signal too weak for current conditions; enqueue instead if rssiValue <= dynamicRSSIThreshold { connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date())) // Keep list tidy connectionCandidates.sort { (a, b) in if a.rssi != b.rssi { return a.rssi > b.rssi } return a.discoveredAt < b.discoveredAt } if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax { connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax) } return } // Budget: limit simultaneous central links (connected + connecting) let currentCentralLinks = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count if currentCentralLinks >= maxCentralLinks { // Enqueue as candidate; we'll attempt later as slots open connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date())) // Keep candidate list tidy: prefer stronger RSSI, then recency; cap list connectionCandidates.sort { (a, b) in if a.rssi != b.rssi { return a.rssi > b.rssi } return a.discoveredAt < b.discoveredAt } if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax { connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax) } return } // Rate limit global connect attempts let sinceLast = Date().timeIntervalSince(lastGlobalConnectAttempt) if sinceLast < connectRateLimitInterval { connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date())) connectionCandidates.sort { (a, b) in if a.rssi != b.rssi { return a.rssi > b.rssi } return a.discoveredAt < b.discoveredAt } // Schedule a deferred attempt after rate-limit interval let delay = connectRateLimitInterval - sinceLast + 0.05 bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() } 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 } } } // Backoff if this peripheral recently timed out connection within the last 15 seconds if let lastTimeout = recentConnectTimeouts[peripheralID], Date().timeIntervalSince(lastTimeout) < 15 { return } // 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(), assembler: NotificationStreamAssembler() ) peripheral.delegate = self // Connect to the peripheral with options for faster connection SecureLogger.debug("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]", category: .session) // Use connection options for faster reconnection let options: [String: Any] = [ CBConnectPeripheralOptionNotifyOnConnectionKey: true, CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, CBConnectPeripheralOptionNotifyOnNotificationKey: true ] central.connect(peripheral, options: options) lastGlobalConnectAttempt = Date() // Set a timeout for the connection attempt (slightly longer for reliability) // Use BLE queue to mutate BLE-related state consistently bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in guard let self = self, let state = self.peripherals[peripheralID], state.isConnecting && !state.isConnected else { return } // Double-check actual CBPeripheral state to avoid canceling a just-connected peripheral // This prevents a race where connection completes just as timeout fires guard peripheral.state != .connected else { SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(advertisedName)", category: .session) return } // Connection timed out - cancel it SecureLogger.debug("⏱️ Timeout: \(advertisedName)", category: .session) central.cancelPeripheralConnection(peripheral) self.peripherals[peripheralID] = nil self.recentConnectTimeouts[peripheralID] = Date() self.failureCounts[peripheralID, default: 0] += 1 // Try next candidate if any self.tryConnectFromQueue() } } 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, lastConnectionAttempt: nil, assembler: NotificationStreamAssembler() ) } // Reset backoff state on success failureCounts[peripheralID] = 0 recentConnectTimeouts.removeValue(forKey: peripheralID) SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session) // Discover services peripheral.discoverServices([BLEService.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.debug("📱 Disconnect: \(peerID?.id ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session) // If disconnect carried an error (often timeout), apply short backoff to avoid thrash if error != nil { recentConnectTimeouts[peripheralID] = Date() } // Clean up references peripherals.removeValue(forKey: peripheralID) // Clean up peer mappings if let peerID { peerToPeripheralUUID.removeValue(forKey: peerID) // Do not remove peer; mark as not connected but retain for reachability collectionsQueue.sync(flags: .barrier) { if var info = peers[peerID] { info.isConnected = false peers[peerID] = info } } refreshLocalTopology() } // 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() bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in self?.startScanning() } } // Attempt to fill freed slot from queue bleQueue.async { [weak self] in self?.tryConnectFromQueue() } // Notify delegate about disconnection on main thread (direct link dropped) notifyUI { [weak self] in guard let self = self else { return } // Get current peer list (after removal) let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } if let peerID { self.notifyPeerDisconnectedDebounced(peerID) } self.requestPeerDataPublish() 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.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) failureCounts[peripheralID, default: 0] += 1 // Try next candidate bleQueue.async { [weak self] in self?.tryConnectFromQueue() } } } // MARK: - Connection scheduling helpers extension BLEService { private func tryConnectFromQueue() { guard let central = centralManager, central.state == .poweredOn else { return } // Check budget and rate limit let current = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count guard current < maxCentralLinks else { return } let delta = Date().timeIntervalSince(lastGlobalConnectAttempt) guard delta >= connectRateLimitInterval else { let delay = connectRateLimitInterval - delta + 0.05 bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() } return } // Pull best candidate by composite score guard !connectionCandidates.isEmpty else { return } // compute score: connectable> RSSI > recency, with backoff penalty func score(_ c: ConnectionCandidate) -> Int { let uuid = c.peripheral.identifier.uuidString // Penalty if recently timed out (exponential) let fails = failureCounts[uuid] ?? 0 let penalty = min(20, (1 << min(4, fails))) // 1,2,4,8,16 cap 16-20 let timeoutRecent = recentConnectTimeouts[uuid] let timeoutBias = (timeoutRecent != nil && Date().timeIntervalSince(timeoutRecent!) < 60) ? 10 : 0 let base = (c.isConnectable ? 1000 : 0) + (c.rssi + 100) * 2 let rec = -Int(Date().timeIntervalSince(c.discoveredAt) * 10) return base + rec - penalty - timeoutBias } connectionCandidates.sort { score($0) > score($1) } let candidate = connectionCandidates.removeFirst() guard candidate.isConnectable else { return } let peripheral = candidate.peripheral let peripheralID = peripheral.identifier.uuidString // Weak-link cooldown: if we recently timed out and RSSI is very weak, delay retries if let lastTO = recentConnectTimeouts[peripheralID] { let elapsed = Date().timeIntervalSince(lastTO) if elapsed < TransportConfig.bleWeakLinkCooldownSeconds && candidate.rssi <= TransportConfig.bleWeakLinkRSSICutoff { // Requeue the candidate and try again later connectionCandidates.append(candidate) let remaining = TransportConfig.bleWeakLinkCooldownSeconds - elapsed let delay = min(max(2.0, remaining), 15.0) bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() } return } } if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true { // Already in progress; skip bleQueue.async { [weak self] in self?.tryConnectFromQueue() } return } // Initiate connection peripherals[peripheralID] = PeripheralState( peripheral: peripheral, characteristic: nil, peerID: nil, isConnecting: true, isConnected: false, lastConnectionAttempt: Date(), assembler: NotificationStreamAssembler() ) peripheral.delegate = self let options: [String: Any] = [ CBConnectPeripheralOptionNotifyOnConnectionKey: true, CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, CBConnectPeripheralOptionNotifyOnNotificationKey: true ] central.connect(peripheral, options: options) lastGlobalConnectAttempt = Date() SecureLogger.debug("⏩ Queue connect: \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session) } } #if DEBUG // Test-only helper to inject packets into the receive pipeline extension BLEService { func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) { if preseedPeer { // Ensure the synthetic peer is known and marked verified for public-message tests let normalizedID = PeerID(hexData: packet.senderID) collectionsQueue.sync(flags: .barrier) { if peers[normalizedID] == nil { peers[normalizedID] = PeerInfo( peerID: normalizedID, nickname: "TestPeer_\(fromPeerID.id.prefix(4))", isConnected: true, noisePublicKey: packet.senderID, signingPublicKey: nil, isVerifiedNickname: true, lastSeen: Date() ) } else { var p = peers[normalizedID]! p.isConnected = true p.isVerifiedNickname = true p.lastSeen = Date() peers[normalizedID] = p } } } handleReceivedPacket(packet, from: fromPeerID) } } #endif // MARK: - CBPeripheralDelegate extension BLEService: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error = error { SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) // Retry service discovery after a delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { guard peripheral.state == .connected else { return } peripheral.discoverServices([BLEService.serviceUUID]) } return } guard let services = peripheral.services else { SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session) return } guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else { // Not a BitChat peer - disconnect centralManager?.cancelPeripheralConnection(peripheral) return } // Discovering BLE characteristics peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service) } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { if let error = error { SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) return } guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else { SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session) 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.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session) } // 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.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session) // Send announce after subscription is confirmed (force send for new connection) messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in self?.sendAnnounce(forceSend: true) // Try flushing any spooled directed packets now that we have a link self?.flushDirectedSpool() } } else { SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session) } } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { if let error = error { SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session) return } guard let data = characteristic.value, !data.isEmpty else { SecureLogger.warning("⚠️ No data in notification", category: .session) return } bufferNotificationChunk(data, from: peripheral) } private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) { let peripheralUUID = peripheral.identifier.uuidString var state = peripherals[peripheralUUID] ?? PeripheralState( peripheral: peripheral, characteristic: nil, peerID: nil, isConnecting: false, isConnected: peripheral.state == .connected, lastConnectionAttempt: nil, assembler: NotificationStreamAssembler() ) var assembler = state.assembler let result = assembler.append(chunk) state.assembler = assembler peripherals[peripheralUUID] = state for byte in result.droppedPrefixes { SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session) } if result.reset { SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session) } // Codex review identified TOCTOU in this patch. // Enforce per-link sender binding immediately within the same notification batch. // NOTE: `processNotificationPacket` may bind `peripherals[peripheralUUID].peerID` when an announce // is processed, but `state` above is a snapshot. Track a local binding that we update as soon as // we see a binding-eligible announce so subsequent frames can't spoof a different sender. var boundPeerID: PeerID? = state.peerID for frame in result.frames { guard let packet = BinaryProtocol.decode(frame) else { let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session) continue } let claimedSenderID = PeerID(hexData: packet.senderID) let trustedSenderID: PeerID? if let knownPeerID = boundPeerID { if knownPeerID != claimedSenderID { SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! Peripheral \(peripheralUUID.prefix(8))… claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(knownPeerID.id.prefix(8))…", category: .security) continue } trustedSenderID = knownPeerID } else { trustedSenderID = nil } if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .peripheral(peripheralUUID)) { continue } // If this is a direct-link announce, bind immediately for the remainder of this batch. if boundPeerID == nil, packet.type == MessageType.announce.rawValue, packet.ttl == messageTTL { boundPeerID = claimedSenderID state.peerID = claimedSenderID peripherals[peripheralUUID] = state } processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID) } } private func processNotificationPacket(_ packet: BitchatPacket, from peripheral: CBPeripheral, peripheralUUID: String) { let senderID = PeerID(hexData: packet.senderID) if packet.type != MessageType.announce.rawValue { SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session) } if packet.type == MessageType.announce.rawValue { if packet.ttl == messageTTL { if var state = peripherals[peripheralUUID] { state.peerID = senderID peripherals[peripheralUUID] = state } peerToPeripheralUUID[senderID] = peripheralUUID refreshLocalTopology() } let msgID = makeMessageID(for: packet) collectionsQueue.async(flags: .barrier) { [weak self] in self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date()) } handleReceivedPacket(packet, from: senderID) } else { let msgID = makeMessageID(for: packet) collectionsQueue.async(flags: .barrier) { [weak self] in self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date()) } handleReceivedPacket(packet, from: senderID) } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if let error = error { SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session) // Don't retry - just log the error } else { SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) } } func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { // Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session) drainPendingWrites(for: peripheral) } func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) // Check if our service was invalidated (peer app quit) let hasOurService = peripheral.services?.contains { $0.uuid == BLEService.serviceUUID } ?? false if !hasOurService { // Service is gone - disconnect SecureLogger.warning("❌ BitChat service removed - disconnecting from \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) centralManager?.cancelPeripheralConnection(peripheral) } else { // Try to rediscover peripheral.discoverServices([BLEService.serviceUUID]) } } func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { if let error = error { SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session) } else { SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session) // 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 BLEService: CBPeripheralManagerDelegate { func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session) switch peripheral.state { case .poweredOn: // Remove all services first to ensure clean state peripheral.removeAllServices() // Create characteristic characteristic = CBMutableCharacteristic( type: BLEService.characteristicUUID, properties: [.notify, .write, .writeWithoutResponse, .read], value: nil, permissions: [.readable, .writeable] ) // Create service let service = CBMutableService(type: BLEService.serviceUUID, primary: true) service.characteristics = [characteristic!] // Add service (advertising will start in didAdd delegate) SecureLogger.debug("🔧 Adding BLE service...", category: .session) peripheral.add(service) case .poweredOff: // Bluetooth was turned off - clean up peripheral state SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session) peripheral.stopAdvertising() // Clear subscribed centrals (they are now invalid) let centralPeerIDs = centralToPeerID.values.map { $0 } subscribedCentrals.removeAll() centralToPeerID.removeAll() centralSubscriptionRateLimits.removeAll() characteristic = nil // Notify UI of disconnections for peerID in centralPeerIDs { notifyUI { [weak self] in self?.notifyPeerDisconnectedDebounced(peerID) } } case .unauthorized: // User denied Bluetooth permission SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session) peripheral.stopAdvertising() subscribedCentrals.removeAll() centralToPeerID.removeAll() centralSubscriptionRateLimits.removeAll() characteristic = nil case .unsupported: // Device doesn't support BLE peripheral role SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session) case .resetting: // Bluetooth stack is resetting SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session) case .unknown: SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session) @unknown default: SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session) } } #if os(iOS) func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) { let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? [] let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:] SecureLogger.info( "♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))", category: .session ) // Attempt to recover characteristic from restored services if characteristic == nil { if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }), let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic { characteristic = restoredCharacteristic } } captureBluetoothStatus(context: "peripheral-restore") if peripheral.state == .poweredOn && !peripheral.isAdvertising { peripheral.startAdvertising(buildAdvertisementData()) } } #endif func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { if let error = error { SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session) return } SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session) // Start advertising after service is confirmed added let adData = buildAdvertisementData() peripheral.startAdvertising(adData) SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session) } func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { let centralUUID = central.identifier.uuidString SecureLogger.debug("📥 Central subscribed: \(centralUUID)", category: .session) subscribedCentrals.append(central) // BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks let now = Date() var state = centralSubscriptionRateLimits[centralUUID] // Clean up stale entries periodically cleanupStaleSubscriptionRateLimits() // Check if this central is rate-limited if let existingState = state { let timeSinceLastAnnounce = now.timeIntervalSince(existingState.lastAnnounceTime) // If within backoff period, skip the announce if timeSinceLastAnnounce < existingState.currentBackoffSeconds { SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(existingState.currentBackoffSeconds))s, attempts: \(existingState.attemptCount))", category: .security) // Increment attempt count and increase backoff // Update lastAnnounceTime to 'now' so each blocked attempt extends the suppression window // This prevents attackers from waiting out the backoff while spamming attempts let newAttemptCount = existingState.attemptCount + 1 let newBackoff = min( existingState.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor, TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds ) centralSubscriptionRateLimits[centralUUID] = SubscriptionRateLimitState( lastAnnounceTime: now, // Reset timer on each blocked attempt attemptCount: newAttemptCount, currentBackoffSeconds: newBackoff ) // If too many rapid attempts, this is likely an enumeration attack - don't respond if newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts { SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security) return } // Still flush directed packets for legitimate mesh operation messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in self?.flushDirectedSpool() } return } // Outside backoff period - allow announce but track it state = SubscriptionRateLimitState( lastAnnounceTime: now, attemptCount: 1, currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds ) } else { // First subscription from this central - track it state = SubscriptionRateLimitState( lastAnnounceTime: now, attemptCount: 1, currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds ) } centralSubscriptionRateLimits[centralUUID] = state // Send announce to the newly subscribed central after a small delay messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in self?.sendAnnounce(forceSend: true) // Flush any spooled directed packets now that we have a central subscribed self?.flushDirectedSpool() } } /// BCH-01-004: Clean up stale rate-limit entries to prevent memory growth private func cleanupStaleSubscriptionRateLimits() { let now = Date() let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds centralSubscriptionRateLimits = centralSubscriptionRateLimits.filter { _, state in now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds } } func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString)", category: .session) subscribedCentrals.removeAll { $0.identifier == central.identifier } // Ensure we're still advertising for other devices to find us if peripheral.isAdvertising == false { SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session) peripheral.startAdvertising(buildAdvertisementData()) } // Find and disconnect the peer associated with this central let centralUUID = central.identifier.uuidString if let peerID = centralToPeerID[centralUUID] { // Mark peer as not connected; retain for reachability collectionsQueue.sync(flags: .barrier) { if var info = peers[peerID] { info.isConnected = false peers[peerID] = info } } // Clean up mappings centralToPeerID.removeValue(forKey: centralUUID) refreshLocalTopology() // Update UI immediately notifyUI { [weak self] in guard let self = self else { return } // Get current peer list (after removal) let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } self.notifyPeerDisconnectedDebounced(peerID) // Publish snapshots so UnifiedPeerService can refresh icons promptly self.requestPeerDataPublish() self.delegate?.didUpdatePeerList(currentPeerIDs) } } } func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { SecureLogger.debug("📤 Peripheral manager ready to send more notifications", category: .session) // 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 var sentCount = 0 for (index, (data, centrals)) in pending.enumerated() { 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 this and all remaining items let remaining = pending.dropFirst(index) self.pendingNotifications.append(contentsOf: remaining) SecureLogger.debug("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session) break // Stop trying, wait for next ready callback } else { sentCount += 1 } } else { // Broadcast to all let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false if !success { // Still full, re-queue this and all remaining items let remaining = pending.dropFirst(index) self.pendingNotifications.append(contentsOf: remaining) SecureLogger.debug("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session) break } else { sentCount += 1 } } } if sentCount > 0 { SecureLogger.debug("✅ Sent \(sentCount) pending notifications from retry queue", category: .session) } if !self.pendingNotifications.isEmpty { SecureLogger.debug("📋 Still have \(self.pendingNotifications.count) pending notifications", category: .session) } } } func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { // Suppress logs for single write requests to reduce noise if requests.count > 1 { SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session) } // 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 writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets. // Combine per-central request values by offset before decoding. // Process directly on our message queue to match transport context let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString }) for (centralUUID, group) in grouped { // Sort by offset ascending let sorted = group.sorted { $0.offset < $1.offset } let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0 // Always merge into a persistent per-central buffer to handle multi-callback long writes var combined = pendingWriteBuffers[centralUUID] ?? Data() var appendedBytes = 0 var offsets: [Int] = [] for r in sorted { guard let chunk = r.value, !chunk.isEmpty else { continue } offsets.append(r.offset) let end = r.offset + chunk.count if combined.count < end { combined.append(Data(repeating: 0, count: end - combined.count)) } // Write chunk into the correct position (supports out-of-order and overlapping writes) combined.replaceSubrange(r.offset..= 2 { let peekType = combined[1] if peekType != MessageType.announce.rawValue { SecureLogger.debug("📥 Accumulated write from central \(centralUUID): size=\(combined.count) (+\(appendedBytes)) bytes (type=\(peekType)), offsets=\(offsets)", category: .session) } } // Try decode the accumulated buffer if let packet = BinaryProtocol.decode(combined) { // Clear buffer on success pendingWriteBuffers.removeValue(forKey: centralUUID) let claimedSenderID = PeerID(hexData: packet.senderID) let trustedSenderID: PeerID? if let knownPeerID = centralToPeerID[centralUUID] { if knownPeerID != claimedSenderID { SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! Central \(centralUUID.prefix(8))… claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(knownPeerID.id.prefix(8))…", category: .security) continue } trustedSenderID = knownPeerID } else { trustedSenderID = nil } if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .central(centralUUID)) { continue } if packet.type != MessageType.announce.rawValue { SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID)", category: .session) } if !subscribedCentrals.contains(sorted[0].central) { subscribedCentrals.append(sorted[0].central) } if packet.type == MessageType.announce.rawValue { if packet.ttl == messageTTL { centralToPeerID[centralUUID] = claimedSenderID refreshLocalTopology() } // Record ingress link for last-hop suppression then process let msgID = makeMessageID(for: packet) collectionsQueue.async(flags: .barrier) { [weak self] in self?.ingressByMessageID[msgID] = (.central(centralUUID), Date()) } handleReceivedPacket(packet, from: claimedSenderID) } else { // Record ingress link for last-hop suppression then process let msgID = makeMessageID(for: packet) collectionsQueue.async(flags: .barrier) { [weak self] in self?.ingressByMessageID[msgID] = (.central(centralUUID), Date()) } handleReceivedPacket(packet, from: claimedSenderID) } } else { // If buffer grows suspiciously large, reset to avoid memory leak if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety pendingWriteBuffers.removeValue(forKey: centralUUID) SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: .session) } // If this was a single short write and still failed, log the raw chunk for debugging if !hasMultiple, let only = sorted.first, let raw = only.value { let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session) } } } } } // MARK: - Advertising Builders & Alias Rotation extension BLEService { private func buildAdvertisementData() -> [String: Any] { let data: [String: Any] = [ CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID] ] // No Local Name for privacy return data } // No alias rotation or advertising restarts required. } // MARK: - Private Helpers extension BLEService { /// Notify UI on the MainActor to satisfy Swift concurrency isolation private func notifyUI(_ block: @escaping () -> Void) { // Always hop onto the MainActor so calls to @MainActor delegates are safe Task { @MainActor in block() } } private func logBluetoothStatus(_ context: String) { bleQueue.async { [weak self] in guard let self = self else { return } self.captureBluetoothStatus(context: context) } } private func scheduleBluetoothStatusSample(after delay: TimeInterval, context: String) { bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self else { return } self.captureBluetoothStatus(context: context) } } private func captureBluetoothStatus(context: String) { assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, "captureBluetoothStatus must run on bleQueue") let centralState = centralManager?.state ?? .unknown let isScanning = centralManager?.isScanning ?? false let peripheralState = peripheralManager?.state ?? .unknown let isAdvertising = peripheralManager?.isAdvertising ?? false let peerSummary = collectionsQueue.sync { ( connected: peers.values.filter { $0.isConnected }.count, known: peers.count, candidates: connectionCandidates.count ) } #if os(iOS) var backgroundDescriptor = "" var backgroundSeconds: TimeInterval = 0 DispatchQueue.main.sync { backgroundSeconds = UIApplication.shared.backgroundTimeRemaining } if backgroundSeconds == .greatestFiniteMagnitude { backgroundDescriptor = " bgRemaining=∞" } else { backgroundDescriptor = String(format: " bgRemaining=%.1fs", backgroundSeconds) } let appPhase = isAppActive ? "foreground" : "background" #else let backgroundDescriptor = "" let appPhase = "foreground" #endif SecureLogger.info( "📊 BLE status [\(context)]: phase=\(appPhase) central=\(centralState) scanning=\(isScanning) peripheral=\(peripheralState) advertising=\(isAdvertising) connected=\(peerSummary.connected) known=\(peerSummary.known) candidates=\(peerSummary.candidates)\(backgroundDescriptor)", category: .session ) } private func routingData(for peerID: PeerID) -> Data? { peerID.toShort().routingData } private func refreshLocalTopology() { let neighbors: [Data] = collectionsQueue.sync { peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData } } meshTopology.updateNeighbors(for: myPeerIDData, neighbors: neighbors) } private func computeRoute(to peerID: PeerID) -> [Data]? { meshTopology.computeRoute(from: myPeerIDData, to: routingData(for: peerID)) } private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket { guard let route = computeRoute(to: recipient), route.count >= 1 else { return packet } // Create new packet with route applied and version upgraded to 2 let routedPacket = BitchatPacket( type: packet.type, senderID: packet.senderID, recipientID: packet.recipientID, timestamp: packet.timestamp, payload: packet.payload, signature: nil, // Will be re-signed below ttl: packet.ttl, version: 2, route: route ) // Re-sign the packet since route and version changed guard let signedPacket = noiseService.signPacket(routedPacket) else { SecureLogger.error("❌ Failed to re-sign packet with route", category: .security) return packet // Return original packet if signing fails } return signedPacket } private func routingPeer(from data: Data) -> PeerID? { PeerID(routingData: data) } private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool { guard let route = packet.route, !route.isEmpty else { return false } let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData) guard let selfData = myRoutingData else { return false } // Route contains only intermediate hops (start and end excluded) // If we're not in the route, we're the sender - forward to first hop guard let index = route.firstIndex(of: selfData) else { // We're the sender, forward to first intermediate hop guard packet.ttl > 1 else { return true } let firstHopData = route[0] guard let nextPeer = routingPeer(from: firstHopData), isPeerConnected(nextPeer) else { return false } var relayPacket = packet relayPacket.ttl = packet.ttl - 1 sendPacketDirected(relayPacket, to: nextPeer) return true } // We're an intermediate node in the route // If we're the last intermediate hop, forward to destination if index == route.count - 1 { guard packet.ttl > 1 else { return true } guard let destinationPeer = PeerID(hexData: packet.recipientID), isPeerConnected(destinationPeer) else { return false } var relayPacket = packet relayPacket.ttl = packet.ttl - 1 sendPacketDirected(relayPacket, to: destinationPeer) return true } // Forward to next intermediate hop guard packet.ttl > 1 else { return true } let nextHopData = route[index + 1] guard let nextPeer = routingPeer(from: nextHopData), isPeerConnected(nextPeer) else { return false } var relayPacket = packet relayPacket.ttl = packet.ttl - 1 sendPacketDirected(relayPacket, to: nextPeer) return true } /// Safely fetch the current direct-link state for a peer using the BLE queue. private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) { let computeState = { () -> (Bool, Bool) in let peripheralUUID = self.peerToPeripheralUUID[peerID] let hasPeripheral = peripheralUUID.flatMap { self.peripherals[$0]?.isConnected } ?? false let hasCentral = self.centralToPeerID.values.contains(peerID) return (hasPeripheral, hasCentral) } if DispatchQueue.getSpecific(key: bleQueueKey) != nil { return computeState() } else { return bleQueue.sync { computeState() } } } private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) { service.onPeerAuthenticated = { [weak self] peerID, fingerprint in SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...") self?.messageQueue.async { [weak self] in self?.sendPendingMessagesAfterHandshake(for: peerID) self?.sendPendingNoisePayloadsAfterHandshake(for: peerID) } self?.messageQueue.async { [weak self] in self?.sendAnnounce(forceSend: true) } } } private func refreshPeerIdentity() { let fingerprint = noiseService.getIdentityFingerprint() myPeerID = PeerID(str: fingerprint.prefix(16)) myPeerIDData = Data(hexString: myPeerID.id) ?? Data() meshTopology.reset() } private func sendNoisePayload(_ typedPayload: Data, to peerID: PeerID) { guard noiseService.hasSession(with: peerID) else { // No session yet - queue the payload SYNCHRONOUSLY before initiating handshake // to prevent race where fast handshake completion drains empty queue collectionsQueue.sync(flags: .barrier) { if self.pendingNoisePayloadsAfterHandshake[peerID] == nil { self.pendingNoisePayloadsAfterHandshake[peerID] = [] } self.pendingNoisePayloadsAfterHandshake[peerID]?.append(typedPayload) SecureLogger.debug("📥 Queued noise payload for \(peerID) pending handshake", category: .session) } initiateNoiseHandshake(with: peerID) return } do { let encrypted = try noiseService.encrypt(typedPayload, for: peerID) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, signature: nil, ttl: messageTTL ) broadcastPacket(packet) } catch { SecureLogger.error("Failed to send verification payload: \(error)") } } // MARK: Link capability snapshots (thread-safe via bleQueue) private func snapshotPeripheralStates() -> [PeripheralState] { if DispatchQueue.getSpecific(key: bleQueueKey) != nil { return Array(peripherals.values) } else { return bleQueue.sync { Array(peripherals.values) } } } private func snapshotSubscribedCentrals() -> ([CBCentral], [String: PeerID]) { if DispatchQueue.getSpecific(key: bleQueueKey) != nil { return (self.subscribedCentrals, self.centralToPeerID) } else { return bleQueue.sync { (self.subscribedCentrals, self.centralToPeerID) } } } // MARK: Helpers: IDs, selection, and write backpressure private func makeMessageID(for packet: BitchatPacket) -> String { let senderID = packet.senderID.hexEncodedString() let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString() return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)" } private func subsetSizeForFanout(_ n: Int) -> Int { guard n > 0 else { return 0 } if n <= 2 { return n } // approx ceil(log2(n)) + 1 without floating point var v = n - 1 var bits = 0 while v > 0 { v >>= 1; bits += 1 } return min(n, max(1, bits + 1)) } private func selectDeterministicSubset(ids: [String], k: Int, seed: String) -> Set { guard k > 0 && ids.count > k else { return Set(ids) } // Stable order by SHA256(seed || "::" || id) var scored: [(score: [UInt8], id: String)] = [] for id in ids { let msg = (seed + "::" + id).data(using: .utf8) ?? Data() let digest = Array(SHA256.hash(data: msg)) scored.append((digest, id)) } scored.sort { a, b in for i in 0.. OutboundPriority { guard let messageType = MessageType(rawValue: packet.type) else { return .low } switch messageType { case .fragment: let total = fragmentTotalCount(from: packet.payload) return OutboundPriority.fragment(totalFragments: total) case .fileTransfer: return .fileTransfer default: return .high } } private func fragmentTotalCount(from payload: Data) -> Int { guard payload.count >= 12 else { return Int(UInt16.max) } let totalHigh = Int(payload[10]) let totalLow = Int(payload[11]) let total = (totalHigh << 8) | totalLow return max(total, 1) } private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic, priority: OutboundPriority) { // BLE operations run on bleQueue; keep queue affinity bleQueue.async { [weak self] in guard let self = self else { return } let uuid = peripheral.identifier.uuidString if peripheral.canSendWriteWithoutResponse { peripheral.writeValue(data, for: characteristic, type: .withoutResponse) } else { self.collectionsQueue.async(flags: .barrier) { var queue = self.pendingPeripheralWrites[uuid] ?? [] let capBytes = TransportConfig.blePendingWriteBufferCapBytes let newSize = data.count // If single chunk exceeds cap, drop it immediately if newSize > capBytes { SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session) } else { let item = PendingWrite(priority: priority, data: data) var total = queue.reduce(0) { $0 + $1.data.count } + newSize let insertIndex = queue.firstIndex { item.priority < $0.priority } ?? queue.count queue.insert(item, at: insertIndex) if total > capBytes { var removedBytes = 0 while total > capBytes && !queue.isEmpty { let removed = queue.removeLast() removedBytes += removed.data.count total -= removed.data.count } if removedBytes > 0 { SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session) } } self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue } } } } } private func drainPendingWrites(for peripheral: CBPeripheral) { let uuid = peripheral.identifier.uuidString bleQueue.async { [weak self] in guard let self = self else { return } guard let state = self.peripherals[uuid], let ch = state.characteristic else { return } // Atomically take all pending items from the queue to avoid race conditions // where new items could be enqueued between read and update let itemsToSend: [PendingWrite] = self.collectionsQueue.sync(flags: .barrier) { let items = self.pendingPeripheralWrites[uuid] ?? [] self.pendingPeripheralWrites[uuid] = nil return items } guard !itemsToSend.isEmpty else { return } // Send as many as possible var sent = 0 for item in itemsToSend { if peripheral.canSendWriteWithoutResponse { peripheral.writeValue(item.data, for: ch, type: .withoutResponse) sent += 1 } else { break } } // Re-enqueue any items that couldn't be sent (maintaining order) let unsent = Array(itemsToSend.dropFirst(sent)) if !unsent.isEmpty { self.collectionsQueue.async(flags: .barrier) { var existing = self.pendingPeripheralWrites[uuid] ?? [] // Prepend unsent items to maintain priority order existing.insert(contentsOf: unsent, at: 0) self.pendingPeripheralWrites[uuid] = existing } } } } /// Periodically try to drain pending notifications as a backup mechanism private func drainPendingNotificationsIfPossible() { 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() var sentCount = 0 for (index, (data, centrals)) in pending.enumerated() { let success: Bool if let centrals = centrals { success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false } else { success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false } if !success { // Re-queue this and all remaining items let remaining = pending.dropFirst(index) self.pendingNotifications.append(contentsOf: remaining) break } else { sentCount += 1 } } if sentCount > 0 { SecureLogger.debug("🔄 Periodic drain: sent \(sentCount) pending notifications", category: .session) } } } /// Periodically try to drain pending writes for all connected peripherals private func drainAllPendingWrites() { let uuids = collectionsQueue.sync { Array(pendingPeripheralWrites.keys) } for uuid in uuids { guard let state = peripherals[uuid], state.isConnected else { continue } drainPendingWrites(for: state.peripheral) } } // 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() } logBluetoothStatus("became-active") scheduleBluetoothStatusSample(after: 5.0, context: "active-5s") // No Local Name; nothing to refresh for advertising policy } @objc private func appDidEnterBackground() { isAppActive = false // Restart scanning without allow duplicates in background if centralManager?.state == .poweredOn { centralManager?.stopScan() startScanning() } logBluetoothStatus("entered-background") scheduleBluetoothStatusSample(after: 15.0, context: "background-15s") // No Local Name; nothing to refresh for advertising policy } #endif // MARK: Private Message Handling private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) { SecureLogger.debug("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: .session) // 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.error("Failed to encode private message with TLV") 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.id 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: myPeerIDData, recipientID: recipientData, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, signature: nil, ttl: messageTTL ) broadcastPacket(packet) // Notify delegate that message was sent notifyUI { [weak self] in self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) } } catch { SecureLogger.error("Failed to encrypt message: \(error)") } } else { // Queue message for sending after handshake completes SecureLogger.debug("🤝 No session with \(recipientID), initiating handshake and queueing message", category: .session) // 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: PeerID) { // 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: myPeerIDData, recipientID: Data(hexString: peerID.id), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: handshakeData, signature: nil, ttl: messageTTL ) broadcastPacket(packet) } catch { SecureLogger.error("Failed to initiate handshake: \(error)") } } private func sendPendingMessagesAfterHandshake(for peerID: PeerID) { // Atomically take all pending messages to process (prevents concurrent modification) 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.debug("📤 Sending \(messages.count) pending messages after handshake to \(peerID)", category: .session) // Track failed messages for re-queuing var failedMessages: [(content: String, messageID: String)] = [] // Send each pending message directly (we know session is established) for (content, messageID) in messages { do { // Use the same TLV format as normal sends to keep receiver decoding consistent let privateMessage = PrivateMessagePacket(messageID: messageID, content: content) guard let tlvData = privateMessage.encode() else { SecureLogger.error("Failed to encode pending private message TLV") failedMessages.append((content, messageID)) continue } var messagePayload = Data([NoisePayloadType.privateMessage.rawValue]) messagePayload.append(tlvData) let encrypted = try noiseService.encrypt(messagePayload, for: peerID) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), 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.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session) } catch { SecureLogger.error("Failed to send pending message after handshake: \(error)") failedMessages.append((content, messageID)) // Notify delegate of failure notifyUI { [weak self] in self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed")) } } } // Re-queue any failed messages for retry on next handshake if !failedMessages.isEmpty { collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } if self.pendingMessagesAfterHandshake[peerID] == nil { self.pendingMessagesAfterHandshake[peerID] = [] } // Prepend failed messages to maintain order self.pendingMessagesAfterHandshake[peerID]?.insert(contentsOf: failedMessages, at: 0) SecureLogger.warning("⚠️ Re-queued \(failedMessages.count) failed messages for \(peerID)", category: .session) } } } // MARK: Fragmentation (Required for messages > BLE MTU) private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil, transferId: String? = nil) { let context = PendingFragmentTransfer(packet: packet, pad: pad, maxChunk: maxChunk, directedPeer: directedOnlyPeer, transferId: transferId) if packet.type == MessageType.fileTransfer.rawValue { let shouldQueue = collectionsQueue.sync { self.activeTransfers.count >= TransportConfig.bleMaxConcurrentTransfers } if shouldQueue { queueFragmentTransfer(context, prioritizeFront: false) return } } startFragmentedPacket(context) } private func queueFragmentTransfer(_ context: PendingFragmentTransfer, prioritizeFront: Bool) { collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } if prioritizeFront { self.pendingFragmentTransfers.insert(context, at: 0) } else { self.pendingFragmentTransfers.append(context) } } if let transferId = context.transferId { SecureLogger.debug("🚦 Queued media transfer \(transferId.prefix(8))… waiting for slot", category: .session) } else { SecureLogger.debug("🚦 Queued fragment transfer waiting for slot", category: .session) } } private func startFragmentedPacket(_ context: PendingFragmentTransfer) { let packet = context.packet let isFileTransfer = packet.type == MessageType.fileTransfer.rawValue var reservedTransferId: String? let releaseReservedSlot: (String) -> Void = { id in TransferProgressManager.shared.cancel(id: id) self.collectionsQueue.async(flags: .barrier) { [weak self] in self?.activeTransfers.removeValue(forKey: id) } self.messageQueue.async { [weak self] in self?.startNextPendingTransferIfNeeded() } } if isFileTransfer { let candidateId = context.transferId ?? packet.payload.sha256Hex() var didReserve = false collectionsQueue.sync(flags: .barrier) { if self.activeTransfers.count < TransportConfig.bleMaxConcurrentTransfers, self.activeTransfers[candidateId] == nil { self.activeTransfers[candidateId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: []) didReserve = true } } guard didReserve else { queueFragmentTransfer(context, prioritizeFront: true) return } reservedTransferId = candidateId } guard let fullData = packet.toBinaryData(padding: context.pad) else { if let id = reservedTransferId { releaseReservedSlot(id) } return } // Fragment the unpadded frame; each fragment will be encoded independently let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) // Dynamic Fragment Sizing (Source Routing v2) // See docs/SOURCE_ROUTING.md Section 5.1 var fragmentVersion: UInt8 = 1 var calculatedChunk = defaultFragmentSize if let route = packet.route, !route.isEmpty { fragmentVersion = 2 // RouteSize = 1 + (Hops * 8) let routeSize = 1 + (route.count * 8) // Overhead = HeaderV2(16) + SenderID(8) + RecipientID(8) + RouteSize + FragmentHeader(13) + PaddingBuffer(16) let overhead = 16 + 8 + 8 + routeSize + 13 + 16 calculatedChunk = max(64, bleMaxMTU - overhead) } let chunk = context.maxChunk ?? calculatedChunk let safeChunk = max(64, chunk) let fragments = stride(from: 0, to: fullData.count, by: safeChunk).map { offset in Data(fullData[offset.. 4 { bleQueue.async { [weak self] in guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return } if c.isScanning { c.stopScan() } let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs) self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in self?.startScanning() } } } let perFragMs = (context.directedPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs let transferIdentifier: String? = { guard let id = reservedTransferId else { return nil } collectionsQueue.sync(flags: .barrier) { self.activeTransfers[id] = ActiveTransferState(totalFragments: totalFragments, sentFragments: 0, workItems: []) } TransferProgressManager.shared.start(id: id, totalFragments: totalFragments) return id }() var scheduledItems: [(item: DispatchWorkItem, index: Int)] = [] for (index, fragment) in fragments.enumerated() { var payload = Data() payload.append(fragmentID) payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) }) payload.append(contentsOf: withUnsafeBytes(of: UInt16(fragments.count).bigEndian) { Data($0) }) payload.append(packet.type) payload.append(fragment) let fragmentRecipient: Data? = { if let only = context.directedPeer { return Data(hexString: only.id) } return packet.recipientID }() let fragmentPacket = BitchatPacket( type: MessageType.fragment.rawValue, senderID: packet.senderID, recipientID: fragmentRecipient, timestamp: packet.timestamp, payload: payload, signature: nil, ttl: packet.ttl, version: fragmentVersion, route: packet.route, isRSR: packet.isRSR ) let workItem = DispatchWorkItem { [weak self] in guard let self = self else { return } if let transferId = transferIdentifier { let isActive = self.collectionsQueue.sync { self.activeTransfers[transferId] != nil } guard isActive else { return } } if fragmentRecipient == nil || fragmentRecipient?.allSatisfy({ $0 == 0xFF }) == true { self.gossipSyncManager?.onPublicPacketSeen(fragmentPacket) } self.broadcastPacket(fragmentPacket) if let transferId = transferIdentifier { self.markFragmentSent(transferId: transferId) } } scheduledItems.append((item: workItem, index: index)) } if let transferId = transferIdentifier { let workItems = scheduledItems.map { $0.item } collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self, var state = self.activeTransfers[transferId] else { return } state.workItems = workItems self.activeTransfers[transferId] = state } } for (workItem, index) in scheduledItems { let delayMs = index * perFragMs messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem) } } // MARK: - Fragmentation (Required for messages > BLE MTU) private func markFragmentSent(transferId: String) { collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self, var state = self.activeTransfers[transferId] else { return } state.sentFragments = min(state.sentFragments + 1, state.totalFragments) let isComplete = state.sentFragments >= state.totalFragments if isComplete { self.activeTransfers.removeValue(forKey: transferId) } else { self.activeTransfers[transferId] = state } TransferProgressManager.shared.recordFragmentSent(id: transferId) if isComplete { self.messageQueue.async { [weak self] in self?.startNextPendingTransferIfNeeded() } } } } private func startNextPendingTransferIfNeeded() { collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } let limit = TransportConfig.bleMaxConcurrentTransfers var availableSlots = max(0, limit - self.activeTransfers.count) guard availableSlots > 0, !self.pendingFragmentTransfers.isEmpty else { return } var toStart: [PendingFragmentTransfer] = [] while availableSlots > 0, !self.pendingFragmentTransfers.isEmpty { toStart.append(self.pendingFragmentTransfers.removeFirst()) availableSlots -= 1 } for context in toStart { self.messageQueue.async { [weak self] in self?.startFragmentedPacket(context) } } } } private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) { if DispatchQueue.getSpecific(key: messageQueueKey) != nil { _handleFragment(packet, from: peerID) } else { messageQueue.async(flags: .barrier) { [weak self] in self?._handleFragment(packet, from: peerID) } } } private func _handleFragment(_ packet: BitchatPacket, from peerID: PeerID) { // Don't process our own fragments if peerID == myPeerID { return } // Minimum header: 8 bytes ID + 2 index + 2 total + 1 type guard packet.payload.count >= 13 else { return } // Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian var senderU64: UInt64 = 0 for b in packet.senderID.prefix(8) { senderU64 = (senderU64 << 8) | UInt64(b) } var fragU64: UInt64 = 0 for b in packet.payload.prefix(8) { fragU64 = (fragU64 << 8) | UInt64(b) } // Parse big-endian UInt16 safely without alignment assumptions let idxHi = UInt16(packet.payload[8]) let idxLo = UInt16(packet.payload[9]) let index = Int((idxHi << 8) | idxLo) let totHi = UInt16(packet.payload[10]) let totLo = UInt16(packet.payload[11]) let total = Int((totHi << 8) | totLo) let originalType = packet.payload[12] let fragmentData = packet.payload.suffix(from: 13) // Sanity checks - add reasonable upper bound on total to prevent DoS guard total > 0 && total <= 10000 && index >= 0 && index < total else { return } let isBroadcastFragment: Bool = { guard let recipient = packet.recipientID else { return true } return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF } }() if isBroadcastFragment { gossipSyncManager?.onPublicPacketSeen(packet) } // Compute fragment key for this assembly let key = FragmentKey(sender: senderU64, id: fragU64) // Critical section: Store fragment and check completion status var shouldReassemble: Bool = false var fragmentsToReassemble: [Int: Data]? = nil collectionsQueue.sync(flags: .barrier) { if incomingFragments[key] == nil { // Cap in-flight assemblies to prevent memory/battery blowups if incomingFragments.count >= maxInFlightAssemblies { // Evict the oldest assembly by timestamp if let oldest = fragmentMetadata.min(by: { $0.value.timestamp < $1.value.timestamp })?.key { incomingFragments.removeValue(forKey: oldest) fragmentMetadata.removeValue(forKey: oldest) } } incomingFragments[key] = [:] fragmentMetadata[key] = (originalType, total, Date()) SecureLogger.debug("📦 Started fragment assembly id=\(String(format: "%016llx", fragU64)) total=\(total)", category: .session) } // Check cumulative size before storing this fragment let currentSize = incomingFragments[key]?.values.reduce(0) { $0 + $1.count } ?? 0 let assemblyLimit: Int = { if originalType == MessageType.fileTransfer.rawValue { // Allow headroom for TLV metadata and binary framing overhead. return FileTransferLimits.maxFramedFileBytes } return FileTransferLimits.maxPayloadBytes }() let projectedSize = currentSize + fragmentData.count guard projectedSize <= assemblyLimit else { // Exceeds size limit - evict this assembly SecureLogger.warning( "🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(assemblyLimit)), evicting. Type=\(originalType) Index=\(index)/\(total)", category: .security ) incomingFragments.removeValue(forKey: key) fragmentMetadata.removeValue(forKey: key) shouldReassemble = false fragmentsToReassemble = nil return } incomingFragments[key]?[index] = Data(fragmentData) SecureLogger.debug("📦 Fragment \(index + 1)/\(total) (len=\(fragmentData.count)) for id=\(String(format: "%016llx", fragU64))", category: .session) // Check if complete if let fragments = incomingFragments[key], fragments.count == total { shouldReassemble = true fragmentsToReassemble = fragments } else { shouldReassemble = false fragmentsToReassemble = nil } } // Heavy work outside lock: reassemble and decode guard shouldReassemble, let fragments = fragmentsToReassemble else { return } var reassembled = Data() for i in 0.. 2 { collectionsQueue.async(flags: .barrier) { [weak self] in if let task = self?.scheduledRelays.removeValue(forKey: messageID) { task.cancel() } } } return // Duplicate ignored } // Update peer info without verbose logging - update the peer we received from, not the original sender updatePeerLastSeen(peerID) // Track recent traffic timestamps for adaptive behavior collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } let now = Date() self.recentPacketTimestamps.append(now) // keep last N timestamps within window let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds) if self.recentPacketTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount { self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount) } self.recentPacketTimestamps.removeAll { $0 < cutoff } } // Process by type switch MessageType(rawValue: packet.type) { case .announce: handleAnnounce(packet, from: senderID) case .message: handleMessage(packet, from: senderID) case .requestSync: handleRequestSync(packet, from: senderID) case .noiseHandshake: handleNoiseHandshake(packet, from: senderID) case .noiseEncrypted: handleNoiseEncrypted(packet, from: senderID) case .fragment: handleFragment(packet, from: senderID) case .fileTransfer: handleFileTransfer(packet, from: senderID) case .leave: handleLeave(packet, from: senderID) case .none: SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session) break } if forwardAlongRouteIfNeeded(packet) { return } // Relay if TTL > 1 and we're not the original sender // Relay decision and scheduling (extracted via RelayController) do { let degree = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } let decision = RelayController.decide( ttl: packet.ttl, senderIsSelf: senderID == myPeerID, isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue, isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil), isFragment: packet.type == MessageType.fragment.rawValue, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isAnnounce: packet.type == MessageType.announce.rawValue, degree: degree, highDegreeThreshold: highDegreeThreshold ) guard decision.shouldRelay else { return } let work = DispatchWorkItem { [weak self] in guard let self = self else { return } // Remove scheduled task before executing self.collectionsQueue.async(flags: .barrier) { [weak self] in _ = self?.scheduledRelays.removeValue(forKey: messageID) } var relayPacket = packet relayPacket.ttl = decision.newTTL self.broadcastPacket(relayPacket) } // Track the scheduled relay so duplicates can cancel it collectionsQueue.async(flags: .barrier) { [weak self] in self?.scheduledRelays[messageID] = work } messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work) } } private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { guard let announcement = AnnouncementPacket.decode(from: packet.payload) else { SecureLogger.error("❌ Failed to decode announce packet from \(peerID)", category: .session) return } // Verify that the sender's derived ID from the announced noise public key matches the packet senderID // This helps detect relayed or spoofed announces. Only warn in release; assert in debug. let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey) if derivedFromKey != peerID { SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security) return } // Don't add ourselves as a peer if peerID == myPeerID { return } // Reject stale announces to prevent ghost peers from appearing // Use same 15-minute window as gossip sync (900 seconds) let maxAnnounceAgeSeconds: TimeInterval = 900 let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) let ageThresholdMs = UInt64(maxAnnounceAgeSeconds * 1000) if nowMs >= ageThresholdMs { let cutoffMs = nowMs - ageThresholdMs if packet.timestamp < cutoffMs { SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(Double(nowMs - packet.timestamp) / 1000.0)s)", category: .session) return } } // Suppress announce logs to reduce noise // Precompute signature verification outside barrier to reduce contention let existingPeerForVerify = collectionsQueue.sync { peers[peerID] } var verifiedAnnounce = false if packet.signature != nil { verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) if !verifiedAnnounce { SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security) } } if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey { SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) verifiedAnnounce = false } // Track if this is a new or reconnected peer var isNewPeer = false var isReconnectedPeer = false let directLinkState = linkState(for: peerID) collectionsQueue.sync(flags: .barrier) { // Check if we have an actual BLE connection to this peer let hasPeripheralConnection = directLinkState.hasPeripheral // 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 let hasCentralSubscription = directLinkState.hasCentral // Direct announces arrive with full TTL (no prior hop) let isDirectAnnounce = (packet.ttl == messageTTL) // 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 // Use precomputed verification result let verified = verifiedAnnounce // Require verified announce; ignore otherwise (no backward compatibility) if !verified { SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))…", category: .security) // Reset flags to prevent post-barrier code from acting on unverified announces isNewPeer = false isReconnectedPeer = false return } // Update or create peer info if let existing = existingPeer, existing.isConnected { // Update lastSeen and identity info peers[peerID] = PeerInfo( peerID: existing.peerID, nickname: announcement.nickname, isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, isVerifiedNickname: true, lastSeen: Date() ) } else { // New peer or reconnecting peer peers[peerID] = PeerInfo( peerID: peerID, nickname: announcement.nickname, isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, isVerifiedNickname: true, lastSeen: Date() ) } // Log connection status only for direct connectivity changes; debounce to reduce spam if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription { let now = Date() if existingPeer == nil { SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session) } else if wasDisconnected { // Debounce 'reconnected' logs within short window if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds { // Skip duplicate log } else { SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session) lastReconnectLogAt[peerID] = now } } else if existingPeer?.nickname != announcement.nickname { SecureLogger.debug("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: .session) } } } // Update topology with verified neighbor claims (only for authenticated announces) if verifiedAnnounce, let neighbors = announcement.directNeighbors { meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors) } // Persist cryptographic identity and signing key for robust offline verification identityManager.upsertCryptographicIdentity( fingerprint: announcement.noisePublicKey.sha256Fingerprint(), noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, claimedNickname: announcement.nickname ) // 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 { self.currentPeerIDs } // Only notify of connection for new or reconnected peers when it is a direct announce if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) { self.delegate?.didConnectToPeer(peerID) // Schedule initial unicast sync to this peer self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0) } self.requestPeerDataPublish() self.delegate?.didUpdatePeerList(currentPeerIDs) } // Track for sync (include our own and others' announces) gossipSyncManager?.onPublicPacketSeen(packet) // 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) } // Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop if isNewPeer { let delay = Double.random(in: 0.3...0.6) messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.sendAnnounce(forceSend: true) } } } // Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) { guard let req = RequestSyncPacket.decode(from: packet.payload) else { SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID)", category: .session) return } gossipSyncManager?.handleRequestSync(from: peerID, request: req) } // Mention parsing moved to ChatViewModel private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) { // Ignore self-origin public messages except when returned via sync (TTL==0). // This allows our own messages to be surfaced when they come back via // the sync path without re-processing regular relayed copies. if peerID == myPeerID && packet.ttl != 0 { return } // Reject stale broadcast messages to prevent old messages from appearing // Use same 15-minute window as gossip sync (900 seconds) // Check if this is a broadcast message (recipient is all 0xFF or nil) let isBroadcast: Bool = { guard let r = packet.recipientID else { return true } return r.count == 8 && r.allSatisfy { $0 == 0xFF } }() if isBroadcast { let maxMessageAgeSeconds: TimeInterval = 900 let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) let ageThresholdMs = UInt64(maxMessageAgeSeconds * 1000) if nowMs >= ageThresholdMs { let cutoffMs = nowMs - ageThresholdMs if packet.timestamp < cutoffMs { SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(Double(nowMs - packet.timestamp) / 1000.0)s)", category: .session) return } } } var accepted = false var senderNickname: String = "" // Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks. let peersSnapshot = collectionsQueue.sync { peers } // If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately if peerID == myPeerID { accepted = true senderNickname = myNickname } else if let info = peersSnapshot[peerID], info.isVerifiedNickname { // Known verified peer path accepted = true senderNickname = info.nickname // Handle nickname collisions let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname) if hasCollision { senderNickname += "#" + String(peerID.id.prefix(4)) } } else { // Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { // Find candidate identities by peerID prefix (16 hex) let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) for candidate in candidates { if let signingKey = candidate.signingPublicKey, noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { accepted = true // Prefer persisted social petname or claimed nickname if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) { senderNickname = social.localPetname ?? social.claimedNickname } else { senderNickname = "anon" + String(peerID.id.prefix(4)) } break } } } } guard accepted else { SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) return } let isBroadcastRecipient: Bool = { guard let r = packet.recipientID else { return true } return r.count == 8 && r.allSatisfy { $0 == 0xFF } }() if isBroadcastRecipient && packet.type == MessageType.message.rawValue { gossipSyncManager?.onPublicPacketSeen(packet) } guard let content = String(data: packet.payload, encoding: .utf8) else { SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session) return } // Determine if we have a direct link to the sender let directLink = linkState(for: peerID) let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral let pathTag = hasDirectLink ? "direct" : "mesh" SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) var resolvedSelfMessageID: String? = nil if peerID == myPeerID { let senderHex = packet.senderID.hexEncodedString() let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)" resolvedSelfMessageID = selfBroadcastMessageIDs.removeValue(forKey: dedupID)?.id } notifyUI { [weak self] in self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts, messageID: resolvedSelfMessageID) } } private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { // Use NoiseEncryptionService for handshake processing if PeerID(hexData: packet.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: myPeerIDData, recipientID: Data(hexString: peerID.id), 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.error("Failed to process handshake: \(error)") // Try initiating a new handshake if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } } } } private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { SecureLogger.debug("🔐 handleNoiseEncrypted called for packet from \(peerID)") guard let recipientID = PeerID(hexData: packet.recipientID) else { SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session) return } if recipientID != myPeerID { SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientID), I am \(myPeerID))", category: .session) 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: let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) notifyUI { [weak self] in self?.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts) } case .delivered: let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) notifyUI { [weak self] in self?.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts) } case .readReceipt: let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) notifyUI { [weak self] in self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts) } case .verifyChallenge: let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) notifyUI { [weak self] in self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts) } case .verifyResponse: let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) notifyUI { [weak self] in self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts) } case .none: SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)") } } catch NoiseEncryptionError.sessionNotEstablished { // We received an encrypted message before establishing a session with this peer. // Trigger a handshake so future messages can be decrypted. SecureLogger.debug("🔑 Encrypted message from \(peerID) without session; initiating handshake") if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } } catch { // Decryption failed - clear the corrupted session and re-initiate handshake // This handles cases where session state got out of sync (nonce mismatch, etc.) SecureLogger.error("❌ Failed to decrypt message from \(peerID): \(error) - clearing session and re-initiating handshake") noiseService.clearSession(for: peerID) initiateNoiseHandshake(with: peerID) } } // MARK: Helper Functions private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) { let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in let list = pendingNoisePayloadsAfterHandshake[peerID] ?? [] pendingNoisePayloadsAfterHandshake.removeValue(forKey: peerID) return list } guard !payloads.isEmpty else { return } SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", category: .session) for payload in payloads { do { let encrypted = try noiseService.encrypt(payload, for: peerID) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, signature: nil, ttl: messageTTL ) broadcastPacket(packet) } catch { SecureLogger.error("❌ Failed to send pending noise payload to \(peerID): \(error)") } } } private func updatePeerLastSeen(_ peerID: PeerID) { // 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 } } } // Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) { let now = Date() let last = recentDisconnectNotifies[peerID] if last == nil || now.timeIntervalSince(last!) >= TransportConfig.bleDisconnectNotifyDebounceSeconds { delegate?.didDisconnectFromPeer(peerID) recentDisconnectNotifies[peerID] = now } else { // Suppressed duplicate disconnect notification } } // NEW: Publish peer snapshots to subscribers and notify Transport delegates private func publishFullPeerData() { let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync { // Compute nickname collision counts for connected peers let connected = peers.values.filter { $0.isConnected } var counts: [String: Int] = [:] for p in connected { counts[p.nickname, default: 0] += 1 } counts[myNickname, default: 0] += 1 return peers.values.map { info in var display = info.nickname if info.isConnected, (counts[info.nickname] ?? 0) > 1 { display += "#" + String(info.peerID.id.prefix(4)) } return TransportPeerSnapshot( peerID: info.peerID, nickname: display, isConnected: info.isConnected, noisePublicKey: info.noisePublicKey, lastSeen: info.lastSeen ) } } // Notify non-UI listeners peerSnapshotSubject.send(transportPeers) // Notify UI on MainActor via delegate Task { @MainActor [weak self] in self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers) } } // MARK: Consolidated Maintenance private func performMaintenance() { maintenanceCounter += 1 // Adaptive announce: reduce frequency when we have connected peers let now = Date() let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } let elapsed = now.timeIntervalSince(lastAnnounceSent) if connectedCount == 0 { // Discovery mode: keep frequent announces if elapsed >= TransportConfig.bleAnnounceIntervalSeconds { sendAnnounce(forceSend: true) } } else { // Connected mode: announce less often; much less in dense networks let base = connectedCount >= TransportConfig.bleHighDegreeThreshold ? TransportConfig.bleConnectedAnnounceBaseSecondsDense : TransportConfig.bleConnectedAnnounceBaseSecondsSparse let jitter = connectedCount >= TransportConfig.bleHighDegreeThreshold ? TransportConfig.bleConnectedAnnounceJitterDense : TransportConfig.bleConnectedAnnounceJitterSparse let target = base + Double.random(in: -jitter...jitter) if elapsed >= target { sendAnnounce(forceSend: true) } } // Activity-driven quick-announce: if we've seen any packet in last 5s and it has // been >=10s since the last announce, send a presence nudge. let recentSeen = collectionsQueue.sync { () -> Bool in let cutoff = now.addingTimeInterval(-5.0) return recentPacketTimestamps.contains(where: { $0 >= cutoff }) } if recentSeen && elapsed >= 10.0 { 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(buildAdvertisementData()) } } // Update scanning duty-cycle based on connectivity updateScanningDutyCycle(connectedCount: connectedCount) updateRSSIThreshold(connectedCount: connectedCount) // Check peer connectivity every cycle for snappier UI updates checkPeerConnectivity() // Every 30 seconds (3 cycles): Cleanup if maintenanceCounter % 3 == 0 { performCleanup() } // Attempt to flush any spooled directed messages periodically (~every 5 seconds) if maintenanceCounter % 2 == 1 { flushDirectedSpool() } // Periodically attempt to drain pending notifications and writes as backup // in case callbacks are missed or delayed (every maintenance cycle = 5 seconds) drainPendingNotificationsIfPossible() drainAllPendingWrites() // No rotating alias: nothing to refresh // Reset counter to prevent overflow (every 60 seconds) if maintenanceCounter >= 6 { maintenanceCounter = 0 } } private func checkPeerConnectivity() { let now = Date() var disconnectedPeers: [PeerID] = [] let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { Array(peers.keys) } var cachedLinkStates: [PeerID: (hasPeripheral: Bool, hasCentral: Bool)] = [:] for peerID in peerIDsForLinkState { cachedLinkStates[peerID] = linkState(for: peerID) } var removedOfflineCount = 0 collectionsQueue.sync(flags: .barrier) { for (peerID, peer) in peers { let age = now.timeIntervalSince(peer.lastSeen) let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds { // Check if we still have an active BLE connection to this peer let state = cachedLinkStates[peerID] ?? (hasPeripheral: false, hasCentral: false) let hasPeripheralConnection = state.hasPeripheral let hasCentralConnection = state.hasCentral // If direct link is gone, mark as not connected (retain entry for reachability) if !hasPeripheralConnection && !hasCentralConnection { var updated = peer updated.isConnected = false peers[peerID] = updated disconnectedPeers.append(peerID) } } // Cleanup: remove peers that are not connected and past reachability retention if !peer.isConnected { if age > retention { SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session) // Also remove any stored announcement from sync candidates gossipSyncManager?.removeAnnouncementForPeer(peerID) peers.removeValue(forKey: peerID) removedOfflineCount += 1 } } } } // Update UI if there were direct disconnections or offline removals if !disconnectedPeers.isEmpty || removedOfflineCount > 0 { notifyUI { [weak self] in guard let self else { return } // Get current peer list (after removal) let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } for peerID in disconnectedPeers { self.delegate?.didDisconnectFromPeer(peerID) } // Publish snapshots so UnifiedPeerService updates connection/reachability icons self.requestPeerDataPublish() self.delegate?.didUpdatePeerList(currentPeerIDs) } } // Refresh local topology to keep our own entry fresh and sync any changes refreshLocalTopology() // Prune stale topology nodes (using safe retention window) meshTopology.prune(olderThan: 60.0) } private func performCleanup() { let now = Date() // Clean old processed messages efficiently messageDeduplicator.cleanup() // Clean old fragments (> configured seconds old) collectionsQueue.sync(flags: .barrier) { let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds) let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key } for fragmentID in oldFragments { incomingFragments.removeValue(forKey: fragmentID) fragmentMetadata.removeValue(forKey: fragmentID) } } // Clean old connection timeout backoff entries (> window) let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds) recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff } // Clean up stale scheduled relays that somehow persisted (> 2s) collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } if !self.scheduledRelays.isEmpty { // Nothing to compare times to; just cap the size defensively if self.scheduledRelays.count > 512 { self.scheduledRelays.removeAll() } } } // Clean ingress link records older than configured seconds collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds) if !self.ingressByMessageID.isEmpty { self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff } } // Clean expired directed spooled items if !self.pendingDirectedRelays.isEmpty { var cleaned: [PeerID: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:] for (recipient, dict) in self.pendingDirectedRelays { let pruned = dict.filter { now.timeIntervalSince($0.value.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds } if !pruned.isEmpty { cleaned[recipient] = pruned } } self.pendingDirectedRelays = cleaned } } messageQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } guard !self.selfBroadcastMessageIDs.isEmpty else { return } let cutoff = now.addingTimeInterval(-TransportConfig.messageDedupMaxAgeSeconds) self.selfBroadcastMessageIDs = self.selfBroadcastMessageIDs.filter { cutoff <= $0.value.timestamp } } } private func updateScanningDutyCycle(connectedCount: Int) { guard let central = centralManager, central.state == .poweredOn else { return } // Duty cycle only when app is active and at least one peer connected #if os(iOS) let active = isAppActive #else let active = true #endif // Force full-time scanning if we have very few neighbors or very recent traffic let hasRecentTraffic: Bool = collectionsQueue.sync { let cutoff = Date().addingTimeInterval(-TransportConfig.bleRecentTrafficForceScanSeconds) return recentPacketTimestamps.contains(where: { $0 >= cutoff }) } let forceScanOn = (connectedCount <= 2) || hasRecentTraffic let shouldDuty = dutyEnabled && active && connectedCount > 0 && !forceScanOn if shouldDuty { if scanDutyTimer == nil { // Start timer to toggle scanning on/off let t = DispatchSource.makeTimerSource(queue: bleQueue) // Start with scanning ON; we'll turn OFF after onDuration if !central.isScanning { startScanning() } dutyActive = true // Adjust duty cycle under dense networks to save battery if connectedCount >= TransportConfig.bleHighDegreeThreshold { dutyOnDuration = TransportConfig.bleDutyOnDurationDense dutyOffDuration = TransportConfig.bleDutyOffDurationDense } else { dutyOnDuration = TransportConfig.bleDutyOnDuration dutyOffDuration = TransportConfig.bleDutyOffDuration } t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) t.setEventHandler { [weak self] in guard let self = self, let c = self.centralManager else { return } if self.dutyActive { // Turn OFF scanning for offDuration if c.isScanning { c.stopScan() } self.dutyActive = false // Schedule turning back ON after offDuration self.bleQueue.asyncAfter(deadline: .now() + self.dutyOffDuration) { if self.centralManager?.state == .poweredOn { self.startScanning() } self.dutyActive = true } } } t.resume() scanDutyTimer = t } } else { // Cancel duty cycle and ensure scanning is ON for discovery scanDutyTimer?.cancel() scanDutyTimer = nil if !central.isScanning { startScanning() } } } private func updateRSSIThreshold(connectedCount: Int) { // Adjust RSSI threshold based on connectivity, candidate pressure, and failures if connectedCount == 0 { // Isolated: relax floor slowly to hunt for distant nodes if lastIsolatedAt == nil { lastIsolatedAt = Date() } let iso = lastIsolatedAt ?? Date() let elapsed = Date().timeIntervalSince(iso) if elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds { dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedRelaxed } else { dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedBase } return } lastIsolatedAt = nil // Base threshold when connected var threshold = TransportConfig.bleDynamicRSSIThresholdDefault // If we're at budget or queue is large, prefer closer peers let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count if linkCount >= maxCentralLinks || connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax { threshold = TransportConfig.bleRSSIConnectedThreshold } // If we have many recent timeouts, raise further let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < TransportConfig.bleRecentTimeoutWindowSeconds }.count if recentTimeouts >= TransportConfig.bleRecentTimeoutCountThreshold { threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold) } dynamicRSSIThreshold = threshold } } ================================================ FILE: bitchat/Services/BLE/MimeType.swift ================================================ // // MimeType.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import UniformTypeIdentifiers // MARK: - Extensions for missing UTTypes extension UTType { static let webP = UTType(importedAs: "image/webp") static let aac = UTType(importedAs: "audio/aac") static let m4a = UTType(importedAs: "audio/m4a") static let ogg = UTType(importedAs: "audio/ogg") } // MARK: - MimeType Enum enum MimeType: CaseIterable, Hashable { case jpeg case jpg case png case gif case webp case mp4Audio case m4a case aac case mpeg case mp3 case wav case xWav case ogg case pdf case octetStream var utType: UTType { switch self { case .jpeg, .jpg: .jpeg case .png: .png case .gif: .gif case .webp: .webP case .aac: .aac case .m4a: .m4a case .mp4Audio: .mpeg4Audio case .mp3, .mpeg: .mp3 case .wav, .xWav: .wav case .ogg: .ogg case .pdf: .pdf case .octetStream: .data } } var category: Category { switch self { case .jpeg, .jpg, .png, .gif, .webp: return .image case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg: return .audio case .pdf, .octetStream: return .file } } var mimeString: String { switch self { case .jpeg, .jpg: "image/jpeg" case .png: "image/png" case .gif: "image/gif" case .webp: "image/webp" case .mp4Audio: "audio/mp4" case .m4a: "audio/m4a" case .aac: "audio/aac" case .mpeg: "audio/mpeg" case .mp3: "audio/mp3" case .wav: "audio/wav" case .xWav: "audio/x-wav" case .ogg: "audio/ogg" case .pdf: "application/pdf" case .octetStream: "application/octet-stream" } } var defaultExtension: String { switch self { case .jpeg, .jpg: "jpg" case .png: "png" case .webp: "webp" case .gif: "gif" case .mp4Audio, .m4a, .aac: "m4a" case .mpeg, .mp3: "mp3" case .wav, .xWav: "wav" case .ogg: "ogg" case .pdf: "pdf" case .octetStream: "bin" } } static var allowed: Set = [ .jpeg, .jpg, .png, .gif, .webp, .mp4Audio, .m4a, .aac, .mpeg, .mp3, .wav, .xWav, .ogg, .pdf, .octetStream ] var isAllowed: Bool { Self.allowed.contains(self) } // MARK: - Byte signature validation func matches(data: Data) -> Bool { guard !data.isEmpty else { return false } // Generic type → skip validation if self == .octetStream { return true } switch self { case .jpeg, .jpg: return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF case .png: return data.count >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 && data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A case .gif: return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61 case .webp: return data.count >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 case .m4a, .mp4Audio, .aac: // AVAudioRecorder output varies by platform - be lenient // Security: size already capped + sandboxed execution return data.count > 100 case .mpeg, .mp3: if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 { return true // ID3 header } return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0 case .wav, .xWav: return data.count >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 && data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45 case .ogg: return data.count >= 4 && data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53 case .pdf: return data.count >= 4 && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 default: return false } } // MARK: - Convenience Initializers init?(_ mimeString: String?) { guard let mimeString else { return nil } let normalized = mimeString.lowercased() // Direct match with our canonical list if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) { self = match return } // Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc. if let type = UTType(mimeType: normalized), let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) { self = match return } return nil } } extension MimeType { enum Category: String { case audio, image, file } } ================================================ FILE: bitchat/Services/CommandProcessor.swift ================================================ // // 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 } /// Simple struct for geo participant info used by CommandProcessor struct CommandGeoParticipant { let id: String // pubkey hex (lowercased) let displayName: String } /// Protocol defining what CommandProcessor needs from its context. /// This breaks the circular dependency between CommandProcessor and ChatViewModel. @MainActor protocol CommandContextProvider: AnyObject { // MARK: - State Properties var nickname: String { get } var selectedPrivateChatPeer: PeerID? { get } var blockedUsers: Set { get } var privateChats: [PeerID: [BitchatMessage]] { get set } var idBridge: NostrIdentityBridge { get } // MARK: - Peer Lookup func getPeerIDForNickname(_ nickname: String) -> PeerID? func getVisibleGeoParticipants() -> [CommandGeoParticipant] func nostrPubkeyForDisplayName(_ displayName: String) -> String? // MARK: - Chat Actions func startPrivateChat(with peerID: PeerID) func sendPrivateMessage(_ content: String, to peerID: PeerID) func clearCurrentPublicTimeline() func sendPublicRaw(_ content: String) // MARK: - System Messages func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) func addPublicSystemMessage(_ content: String) // MARK: - Favorites func toggleFavorite(peerID: PeerID) func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) } /// Processes chat commands in a focused, efficient way @MainActor final class CommandProcessor { weak var contextProvider: CommandContextProvider? weak var meshService: Transport? private let identityManager: SecureIdentityStateManagerProtocol init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) { self.contextProvider = contextProvider self.meshService = meshService self.identityManager = identityManager } /// 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]) : "" // Geohash context: disable favoriting in public geohash or GeoDM let inGeoPublic: Bool = { switch LocationChannelManager.shared.selectedChannel { case .mesh: return false case .location: return true } }() let inGeoDM = contextProvider?.selectedPrivateChatPeer?.isGeoDM == true switch cmd { case "/m", "/msg": return handleMessage(args) case "/w", "/who": return handleWho() case "/clear": return handleClear() case "/hug": return handleEmote(args, command: "hug", action: "hugs", emoji: "🫂") case "/slap": return handleEmote(args, command: "slap", action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout") case "/block": return handleBlock(args) case "/unblock": return handleUnblock(args) case "/fav": if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: true) case "/unfav": if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: false) 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 = contextProvider?.getPeerIDForNickname(nickname) else { return .error(message: "'\(nickname)' not found") } contextProvider?.startPrivateChat(with: peerID) if parts.count > 1 { let message = String(parts[1]) contextProvider?.sendPrivateMessage(message, to: peerID) } return .success(message: "started private chat with \(nickname)") } private func handleWho() -> CommandResult { // Show geohash participants when in a geohash channel; otherwise mesh peers switch LocationChannelManager.shared.selectedChannel { case .location(let ch): // Geohash context: show visible geohash participants (exclude self) guard let vm = contextProvider else { return .success(message: "nobody around") } let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased() let people = vm.getVisibleGeoParticipants().filter { person in if let me = myHex { return person.id.lowercased() != me } return true } let names = people.map { $0.displayName } if names.isEmpty { return .success(message: "no one else is online right now") } return .success(message: "online: " + names.sorted().joined(separator: ", ")) case .mesh: // Mesh context: show connected peer nicknames 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 = contextProvider?.selectedPrivateChatPeer { contextProvider?.privateChats[peerID]?.removeAll() } else { contextProvider?.clearCurrentPublicTimeline() } return .handled } private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult { let targetName = args.trimmingCharacters(in: .whitespaces) guard !targetName.isEmpty else { return .error(message: "usage: /\(command) ") } let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname), let myNickname = contextProvider?.nickname else { return .error(message: "cannot \(command) \(nickname): not found") } let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *" if contextProvider?.selectedPrivateChatPeer != nil { // In private chat if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) { let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *" meshService?.sendPrivateMessage(personalMessage, to: targetPeerID, recipientNickname: peerNickname, messageID: UUID().uuidString) // Also add a local system message so the sender sees a natural-language confirmation let pastAction: String = { switch action { case "hugs": return "hugged" case "slaps": return "slapped" default: return action.hasSuffix("e") ? action + "d" : action + "ed" } }() let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)" contextProvider?.addLocalPrivateSystemMessage(localText, to: targetPeerID) } } else { // In public chat: send to active public channel (mesh or geohash) contextProvider?.sendPublicRaw(emoteContent) let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)" contextProvider?.addPublicSystemMessage(publicEcho) } return .handled } private func handleBlock(_ args: String) -> CommandResult { let targetName = args.trimmingCharacters(in: .whitespaces) if targetName.isEmpty { // List blocked users (mesh) and geohash (Nostr) blocks let meshBlocked = contextProvider?.blockedUsers ?? [] var blockedNicknames: [String] = [] if let peers = meshService?.getPeerNicknames() { for (peerID, nickname) in peers { if let fingerprint = meshService?.getFingerprint(for: peerID), meshBlocked.contains(fingerprint) { blockedNicknames.append(nickname) } } } // Geohash blocked names (prefer visible display names; fallback to #suffix) let geoBlocked = Array(identityManager.getBlockedNostrPubkeys()) var geoNames: [String] = [] if let vm = contextProvider { let visible = vm.getVisibleGeoParticipants() let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) }) for pk in geoBlocked { if let name = visibleIndex[pk.lowercased()] { geoNames.append(name) } else { let suffix = String(pk.suffix(4)) geoNames.append("anon#\(suffix)") } } } let meshList = blockedNicknames.isEmpty ? "none" : blockedNicknames.sorted().joined(separator: ", ") let geoList = geoNames.isEmpty ? "none" : geoNames.sorted().joined(separator: ", ") return .success(message: "blocked peers: \(meshList) | geohash blocks: \(geoList)") } let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName if let peerID = contextProvider?.getPeerIDForNickname(nickname), let fingerprint = meshService?.getFingerprint(for: peerID) { if identityManager.isBlocked(fingerprint: fingerprint) { return .success(message: "\(nickname) is already blocked") } // Block the user (mesh/noise identity) if var identity = identityManager.getSocialIdentity(for: fingerprint) { identity.isBlocked = true identity.isFavorite = false identityManager.updateSocialIdentity(identity) } else { let blockedIdentity = SocialIdentity( fingerprint: fingerprint, localPetname: nil, claimedNickname: nickname, trustLevel: .unknown, isFavorite: false, isBlocked: true, notes: nil ) identityManager.updateSocialIdentity(blockedIdentity) } return .success(message: "blocked \(nickname). you will no longer receive messages from them") } // Mesh lookup failed; try geohash (Nostr) participant by display name if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) { if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) { return .success(message: "\(nickname) is already blocked") } identityManager.setNostrBlocked(pub, isBlocked: true) return .success(message: "blocked \(nickname) in geohash chats") } return .error(message: "cannot block \(nickname): not found or unable to verify identity") } 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 if let peerID = contextProvider?.getPeerIDForNickname(nickname), let fingerprint = meshService?.getFingerprint(for: peerID) { if !identityManager.isBlocked(fingerprint: fingerprint) { return .success(message: "\(nickname) is not blocked") } identityManager.setBlocked(fingerprint, isBlocked: false) return .success(message: "unblocked \(nickname)") } // Try geohash unblock if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) { if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) { return .success(message: "\(nickname) is not blocked") } identityManager.setNostrBlocked(pub, isBlocked: false) return .success(message: "unblocked \(nickname) in geohash chats") } return .error(message: "cannot unblock \(nickname): not found") } 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 = contextProvider?.getPeerIDForNickname(nickname), let noisePublicKey = Data(hexString: peerID.id) 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 ) contextProvider?.toggleFavorite(peerID: peerID) contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true) return .success(message: "added \(nickname) to favorites") } else { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) contextProvider?.toggleFavorite(peerID: peerID) contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false) return .success(message: "removed \(nickname) from favorites") } } } ================================================ FILE: bitchat/Services/FavoritesPersistenceService.swift ================================================ import BitLogger import Foundation import Combine /// Manages persistent favorite relationships between peers @MainActor final class FavoritesPersistenceService: ObservableObject { struct FavoriteRelationship: Codable { let peerNoisePublicKey: Data let peerNostrPublicKey: String? let peerNickname: String let isFavorite: Bool let theyFavoritedUs: Bool let favoritedAt: Date let lastUpdated: Date // Track what we last sent as OUR npub to this peer, to avoid resending unless it changes // Note: we do not track which npub we last sent to them; sending happens only on favorite toggle var isMutual: Bool { isFavorite && theyFavoritedUs } } // We intentionally do not track when we last sent our npub; sending happens only on favorite toggle. private static let storageKey = "chat.bitchat.favorites" private static let keychainService = "chat.bitchat.favorites" private let keychain: KeychainManagerProtocol @Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship @Published private(set) var mutualFavorites: Set = [] static let shared = FavoritesPersistenceService() init(keychain: KeychainManagerProtocol = KeychainManager()) { self.keychain = keychain loadFavorites() // Update mutual favorites when favorites change $favorites .map { favorites in Set(favorites.compactMap { $0.value.isMutual ? $0.key : nil }) } .assign(to: &$mutualFavorites) } /// Add or update a favorite func addFavorite( peerNoisePublicKey: Data, peerNostrPublicKey: String? = nil, peerNickname: String ) { SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session) let existing = favorites[peerNoisePublicKey] let relationship = FavoriteRelationship( peerNoisePublicKey: peerNoisePublicKey, peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey, peerNickname: peerNickname, isFavorite: true, theyFavoritedUs: existing?.theyFavoritedUs ?? false, favoritedAt: existing?.favoritedAt ?? Date(), lastUpdated: Date() ) // Log if this creates a mutual favorite if relationship.isMutual { SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session) } favorites[peerNoisePublicKey] = relationship saveFavorites() // Notify observers NotificationCenter.default.post( name: .favoriteStatusChanged, object: nil, userInfo: ["peerPublicKey": peerNoisePublicKey] ) } /// Remove a favorite func removeFavorite(peerNoisePublicKey: Data) { guard let existing = favorites[peerNoisePublicKey] else { return } SecureLogger.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session) // If they still favorite us, keep the record but mark us as not favoriting if existing.theyFavoritedUs { let updated = FavoriteRelationship( peerNoisePublicKey: existing.peerNoisePublicKey, peerNostrPublicKey: existing.peerNostrPublicKey, peerNickname: existing.peerNickname, isFavorite: false, theyFavoritedUs: true, favoritedAt: existing.favoritedAt, lastUpdated: Date() ) favorites[peerNoisePublicKey] = updated // Keeping record - they still favorite us } else { // Neither side favorites, remove completely favorites.removeValue(forKey: peerNoisePublicKey) // Completely removed from favorites } saveFavorites() // Notify observers NotificationCenter.default.post( name: .favoriteStatusChanged, object: nil, userInfo: ["peerPublicKey": peerNoisePublicKey] ) } /// Update when we learn a peer favorited/unfavorited us func updatePeerFavoritedUs( peerNoisePublicKey: Data, favorited: Bool, peerNickname: String? = nil, peerNostrPublicKey: String? = nil ) { let existing = favorites[peerNoisePublicKey] let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown" SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session) let relationship = FavoriteRelationship( peerNoisePublicKey: peerNoisePublicKey, peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey, peerNickname: displayName, isFavorite: existing?.isFavorite ?? false, theyFavoritedUs: favorited, favoritedAt: existing?.favoritedAt ?? Date(), lastUpdated: Date() ) if !relationship.isFavorite && !relationship.theyFavoritedUs { // Neither side favorites, remove completely favorites.removeValue(forKey: peerNoisePublicKey) // Removed - neither side favorites anymore } else { favorites[peerNoisePublicKey] = relationship // Check if this creates a mutual favorite if relationship.isMutual { SecureLogger.info("💕 Mutual favorite relationship established with \(displayName)!", category: .session) } } saveFavorites() // Notify observers NotificationCenter.default.post( name: .favoriteStatusChanged, object: nil, userInfo: ["peerPublicKey": peerNoisePublicKey] ) } /// Check if a peer is favorited by us func isFavorite(_ peerNoisePublicKey: Data) -> Bool { favorites[peerNoisePublicKey]?.isFavorite ?? false } /// Check if we have a mutual favorite relationship func isMutualFavorite(_ peerNoisePublicKey: Data) -> Bool { favorites[peerNoisePublicKey]?.isMutual ?? false } /// Get favorite status for a peer func getFavoriteStatus(for peerNoisePublicKey: Data) -> FavoriteRelationship? { favorites[peerNoisePublicKey] } /// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey) /// Falls back to scanning favorites and matching on derived peer ID. func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? { // Quick sanity: peerID should be 16 hex chars (8 bytes) guard peerID.isShort else { return nil } for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID { return rel } return nil } /// Clear all favorites - used for panic mode func clearAllFavorites() { SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session) favorites.removeAll() saveFavorites() // Delete from keychain directly keychain.delete( key: Self.storageKey, service: Self.keychainService ) // Post notification for UI update NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil) } // MARK: - Persistence private func saveFavorites() { let relationships = Array(favorites.values) // Saving favorite relationships to keychain do { let encoder = JSONEncoder() let data = try encoder.encode(relationships) // Store in keychain for security keychain.save( key: Self.storageKey, data: data, service: Self.keychainService, accessible: nil ) // Successfully saved favorites } catch { SecureLogger.error("Failed to save favorites: \(error)", category: .session) } } private func loadFavorites() { // Loading favorites from keychain guard let data = keychain.load( key: Self.storageKey, service: Self.keychainService ) else { return } do { let decoder = JSONDecoder() let relationships = try decoder.decode([FavoriteRelationship].self, from: data) SecureLogger.info("✅ Loaded \(relationships.count) favorite relationships", category: .session) // Log Nostr public key info for relationship in relationships { if relationship.peerNostrPublicKey == nil { SecureLogger.warning("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", category: .session) } } // Convert to dictionary, cleaning up duplicates by public key (not nickname) var seenPublicKeys: [Data: FavoriteRelationship] = [:] var cleanedRelationships: [FavoriteRelationship] = [] for relationship in relationships { // Check for duplicates by public key (the actual unique identifier) if let existing = seenPublicKeys[relationship.peerNoisePublicKey] { SecureLogger.warning("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", category: .session) // Keep the most recent or most complete relationship if relationship.lastUpdated > existing.lastUpdated || (relationship.peerNostrPublicKey != nil && existing.peerNostrPublicKey == nil) { // Replace with newer/more complete entry seenPublicKeys[relationship.peerNoisePublicKey] = relationship cleanedRelationships.removeAll { $0.peerNoisePublicKey == relationship.peerNoisePublicKey } cleanedRelationships.append(relationship) } } else { seenPublicKeys[relationship.peerNoisePublicKey] = relationship cleanedRelationships.append(relationship) } } // If we cleaned up duplicates, save the cleaned list if cleanedRelationships.count < relationships.count { // Cleaned up duplicates // Clear and rebuild favorites dictionary favorites.removeAll() for relationship in cleanedRelationships { favorites[relationship.peerNoisePublicKey] = relationship } // Save cleaned favorites saveFavorites() // Notify that favorites have been cleaned up (synchronously since we're already on main actor) NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil) } else { // No duplicates, just populate normally for relationship in cleanedRelationships { favorites[relationship.peerNoisePublicKey] = relationship } } // Log loaded relationships // Loaded relationships successfully } catch { SecureLogger.error("Failed to load favorites: \(error)", category: .session) } } } // MARK: - Notification Names extension Notification.Name { static let favoriteStatusChanged = Notification.Name("FavoriteStatusChanged") } ================================================ FILE: bitchat/Services/GeohashParticipantTracker.swift ================================================ // // GeohashParticipantTracker.swift // bitchat // // Tracks participants in geohash-based location channels. // This is free and unencumbered software released into the public domain. // import Foundation /// Represents a participant in a geohash channel public struct GeoPerson: Identifiable, Equatable, Sendable { public let id: String // pubkey hex (lowercased) public let displayName: String public let lastSeen: Date public init(id: String, displayName: String, lastSeen: Date) { self.id = id self.displayName = displayName self.lastSeen = lastSeen } } /// Protocol for resolving display names and checking block status @MainActor public protocol GeohashParticipantContext: AnyObject { /// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4") func displayNameForPubkey(_ pubkeyHex: String) -> String /// Returns true if the pubkey is blocked func isBlocked(_ pubkeyHexLowercased: String) -> Bool } /// Tracks participants across multiple geohash channels @MainActor public final class GeohashParticipantTracker: ObservableObject { /// Activity cutoff duration (defaults to 5 minutes) public let activityCutoff: TimeInterval /// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]] private var participants: [String: [String: Date]] = [:] /// Currently visible people for the active geohash @Published public private(set) var visiblePeople: [GeoPerson] = [] /// The currently active geohash (if any) private var activeGeohash: String? /// Context for display name resolution and block checking private weak var context: GeohashParticipantContext? /// Timer for periodic refresh private var refreshTimer: Timer? public init(activityCutoff: TimeInterval = -300) { // default 5 minutes self.activityCutoff = activityCutoff } /// Configure with a context provider public func configure(context: GeohashParticipantContext) { self.context = context } /// Set the currently active geohash public func setActiveGeohash(_ geohash: String?) { activeGeohash = geohash if geohash == nil { visiblePeople = [] } else { refresh() } } /// Record activity from a participant in the current active geohash public func recordParticipant(pubkeyHex: String) { guard let gh = activeGeohash else { return } recordParticipant(pubkeyHex: pubkeyHex, geohash: gh) } /// Record activity from a participant in a specific geohash public func recordParticipant(pubkeyHex: String, geohash: String) { let key = pubkeyHex.lowercased() var map = participants[geohash] ?? [:] map[key] = Date() participants[geohash] = map // Always notify observers that state has changed so counts in UI update objectWillChange.send() // Only refresh visible list if this geohash is currently active if activeGeohash == geohash { refresh() } } /// Remove a participant from all geohashes (used when blocking) public func removeParticipant(pubkeyHex: String) { let key = pubkeyHex.lowercased() for (gh, var map) in participants { map.removeValue(forKey: key) participants[gh] = map } refresh() } /// Get participant count for a specific geohash public func participantCount(for geohash: String) -> Int { let cutoff = Date().addingTimeInterval(activityCutoff) let map = participants[geohash] ?? [:] return map.values.filter { $0 >= cutoff }.count } /// Get the visible people list for the active geohash (read-only query) public func getVisiblePeople() -> [GeoPerson] { guard let gh = activeGeohash, let context = context else { return [] } let cutoff = Date().addingTimeInterval(activityCutoff) let map = (participants[gh] ?? [:]) .filter { $0.value >= cutoff } .filter { !context.isBlocked($0.key) } return map .map { (pub, seen) in GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen) } .sorted { $0.lastSeen > $1.lastSeen } } /// Refresh the visible people list public func refresh() { visiblePeople = getVisiblePeople() } /// Start the periodic refresh timer public func startRefreshTimer(interval: TimeInterval = 30.0) { stopRefreshTimer() refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in Task { @MainActor in self?.refresh() } } } /// Stop the periodic refresh timer public func stopRefreshTimer() { refreshTimer?.invalidate() refreshTimer = nil } /// Clear all participant data public func clear() { participants.removeAll() visiblePeople = [] } /// Clear participant data for a specific geohash public func clear(geohash: String) { participants.removeValue(forKey: geohash) if activeGeohash == geohash { visiblePeople = [] } } } ================================================ FILE: bitchat/Services/GeohashPresenceService.swift ================================================ // // GeohashPresenceService.swift // bitchat // // Manages the broadcasting of ephemeral presence heartbeats (Kind 20001) // to geohash location channels. // // This is free and unencumbered software released into the public domain. // import Foundation import Combine import BitLogger import Tor protocol GeohashPresenceTimerProtocol: AnyObject { var isValid: Bool { get } func invalidate() } private final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol { private let base: Timer init(base: Timer) { self.base = base } var isValid: Bool { base.isValid } func invalidate() { base.invalidate() } } /// Service that coordinates the broadcasting of presence heartbeats. /// /// Behavior: /// - Monitors location changes via LocationStateManager /// - Broadcasts Kind 20001 events to low-precision geohash channels /// - Uses randomized timing (40-80s loop) and decorrelated bursts /// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels @MainActor final class GeohashPresenceService: ObservableObject { static let shared = GeohashPresenceService() private var subscriptions = Set() private var heartbeatTimer: GeohashPresenceTimerProtocol? private let availableChannelsProvider: () -> [GeohashChannel] private let locationChanges: AnyPublisher<[GeohashChannel], Never> private let torReadyPublisher: AnyPublisher private let torIsReady: () -> Bool private let torIsForeground: () -> Bool private let deriveIdentity: (String) throws -> NostrIdentity private let relayLookup: (String, Int) -> [String] private let relaySender: (NostrEvent, [String]) -> Void private let sleeper: (UInt64) async -> Void private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol // MARK: - Constants // Loop interval range in seconds private let loopMinInterval: TimeInterval private let loopMaxInterval: TimeInterval // Per-broadcast decorrelation delay range in seconds private let burstMinDelay: TimeInterval private let burstMaxDelay: TimeInterval // Privacy: Only broadcast to these levels private let allowedPrecisions: Set = [ GeohashChannelLevel.region.precision, // 2 GeohashChannelLevel.province.precision, // 4 GeohashChannelLevel.city.precision // 5 ] private init() { let idBridge = NostrIdentityBridge() self.availableChannelsProvider = { LocationStateManager.shared.availableChannels } self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher() self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady) .map { _ in () } .eraseToAnyPublisher() self.torIsReady = { TorManager.shared.isReady } self.torIsForeground = { TorManager.shared.isForeground() } self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) } self.relayLookup = { geohash, count in GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count) } self.relaySender = { event, relays in NostrRelayManager.shared.sendEvent(event, to: relays) } self.sleeper = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) } self.scheduleTimer = { interval, action in GeohashPresenceTimerAdapter( base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in action() } ) } self.loopMinInterval = 40.0 self.loopMaxInterval = 80.0 self.burstMinDelay = 2.0 self.burstMaxDelay = 5.0 setupObservers() } internal init( availableChannelsProvider: @escaping () -> [GeohashChannel], locationChanges: AnyPublisher<[GeohashChannel], Never>, torReadyPublisher: AnyPublisher, torIsReady: @escaping () -> Bool, torIsForeground: @escaping () -> Bool, deriveIdentity: @escaping (String) throws -> NostrIdentity, relayLookup: @escaping (String, Int) -> [String], relaySender: @escaping (NostrEvent, [String]) -> Void, sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) }, scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in GeohashPresenceTimerAdapter( base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in action() } ) }, loopMinInterval: TimeInterval = 40.0, loopMaxInterval: TimeInterval = 80.0, burstMinDelay: TimeInterval = 2.0, burstMaxDelay: TimeInterval = 5.0 ) { self.availableChannelsProvider = availableChannelsProvider self.locationChanges = locationChanges self.torReadyPublisher = torReadyPublisher self.torIsReady = torIsReady self.torIsForeground = torIsForeground self.deriveIdentity = deriveIdentity self.relayLookup = relayLookup self.relaySender = relaySender self.sleeper = sleeper self.scheduleTimer = scheduleTimer self.loopMinInterval = loopMinInterval self.loopMaxInterval = loopMaxInterval self.burstMinDelay = burstMinDelay self.burstMaxDelay = burstMaxDelay setupObservers() } /// Start the service (safe to call multiple times) func start() { SecureLogger.info("Presence: service starting...", category: .session) scheduleNextHeartbeat() } private func setupObservers() { // Monitor location channel changes locationChanges .dropFirst() .sink { [weak self] _ in self?.handleLocationChange() } .store(in: &subscriptions) // Monitor Tor readiness to kick off heartbeat if it was stalled torReadyPublisher .sink { [weak self] _ in self?.handleConnectivityChange() } .store(in: &subscriptions) } func handleLocationChange() { // When location changes, we trigger an immediate (but slightly delayed) heartbeat // to announce presence in the new zone, then reset the loop. SecureLogger.debug("Presence: location changed, scheduling update", category: .session) heartbeatTimer?.invalidate() // Small delay to allow location state to settle heartbeatTimer = scheduleTimer(5.0) { [weak self] in Task { @MainActor [weak self] in self?.performHeartbeat() } } } func handleConnectivityChange() { SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session) // If we were waiting for network, do it now if heartbeatTimer == nil || !heartbeatTimer!.isValid { scheduleNextHeartbeat() } } func scheduleNextHeartbeat() { heartbeatTimer?.invalidate() let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval) heartbeatTimer = scheduleTimer(interval) { [weak self] in Task { @MainActor [weak self] in self?.performHeartbeat() } } } func performHeartbeat() { // Always schedule next loop first ensures continuity even if this one fails/skips defer { scheduleNextHeartbeat() } // 1. Check preconditions guard torIsReady() else { SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session) return } // App must be active (or at least we shouldn't broadcast if in background, usually) if !torIsForeground() { return } // 2. Get channels let channels = availableChannelsProvider() guard !channels.isEmpty else { return } // 3. Filter and broadcast // We use Task + sleep for decorrelation to allow the main runloop to proceed for channel in channels { // Check privacy restriction if !self.allowedPrecisions.contains(channel.geohash.count) { continue } // Launch independent task for each channel's delay Task { @MainActor in // Random delay for decorrelation let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay) let nanoseconds = UInt64(delay * 1_000_000_000) await self.sleeper(nanoseconds) self.broadcastPresence(for: channel.geohash) } } } func broadcastPresence(for geohash: String) { do { guard let identity = try? deriveIdentity(geohash) else { return } let event = try NostrProtocol.createGeohashPresenceEvent( geohash: geohash, senderIdentity: identity ) // Send via RelayManager let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount) if !targetRelays.isEmpty { relaySender(event, targetRelays) SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session) } } catch { SecureLogger.error("Presence: failed to create event for \(geohash): \(error)", category: .session) } } } ================================================ FILE: bitchat/Services/KeychainManager.swift ================================================ // // KeychainManager.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import BitLogger import Foundation import Security // MARK: - Keychain Error Types // BCH-01-009: Proper error classification to distinguish expected states from critical failures /// Result of a keychain read operation with proper error classification enum KeychainReadResult { case success(Data) case itemNotFound // Expected: key doesn't exist yet case accessDenied // Critical: app lacks keychain access case deviceLocked // Recoverable: device is locked case authenticationFailed // Recoverable: biometric/passcode failed case otherError(OSStatus) // Unexpected error var isRecoverableError: Bool { switch self { case .deviceLocked, .authenticationFailed: return true default: return false } } } /// Result of a keychain save operation with proper error classification enum KeychainSaveResult { case success case duplicateItem // Can retry with update case accessDenied // Critical: app lacks keychain access case deviceLocked // Recoverable: device is locked case storageFull // Critical: no space available case otherError(OSStatus) var isRecoverableError: Bool { switch self { case .duplicateItem, .deviceLocked: return true default: return false } } } protocol KeychainManagerProtocol { func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool func getIdentityKey(forKey key: String) -> Data? func deleteIdentityKey(forKey key: String) -> Bool func deleteAllKeychainData() -> Bool func secureClear(_ data: inout Data) func secureClear(_ string: inout String) func verifyIdentityKeyExists() -> Bool // BCH-01-009: Methods with proper error classification /// Get identity key with detailed result for error handling func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult /// Save identity key with detailed result for error handling func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult // MARK: - Generic Data Storage (consolidated from KeychainHelper) /// Save data with a custom service name func save(key: String, data: Data, service: String, accessible: CFString?) /// Load data from a custom service func load(key: String, service: String) -> Data? /// Delete data from a custom service func delete(key: String, service: String) } final class KeychainManager: KeychainManagerProtocol { // Use consistent service name for all keychain items private let service = BitchatApp.bundleID private let appGroup = "group.\(BitchatApp.bundleID)" // MARK: - Identity Keys func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { let fullKey = "identity_\(key)" let result = saveData(keyData, forKey: fullKey) SecureLogger.logKeyOperation(.save, keyType: key, success: result) return result } func getIdentityKey(forKey key: String) -> Data? { let fullKey = "identity_\(key)" return retrieveData(forKey: fullKey) } func deleteIdentityKey(forKey key: String) -> Bool { let result = delete(forKey: "identity_\(key)") SecureLogger.logKeyOperation(.delete, keyType: key, success: result) return result } // MARK: - BCH-01-009: Methods with Proper Error Classification /// Get identity key with detailed result for proper error handling /// Distinguishes between missing keys (expected) and critical failures func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { let fullKey = "identity_\(key)" return retrieveDataWithResult(forKey: fullKey) } /// Save identity key with detailed result and retry logic for transient errors func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { let fullKey = "identity_\(key)" return saveDataWithResult(keyData, forKey: fullKey) } /// Internal method to save data with detailed result and retry for transient errors private func saveDataWithResult(_ data: Data, forKey key: String, retryCount: Int = 2) -> KeychainSaveResult { // Delete any existing item first to ensure clean state _ = delete(forKey: key) // Build base query var base: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecValueData as String: data, kSecAttrService as String: service, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, kSecAttrLabel as String: "bitchat-\(key)" ] #if os(macOS) base[kSecAttrSynchronizable as String] = false #endif func attempt(addAccessGroup: Bool) -> OSStatus { var query = base if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup } return SecItemAdd(query as CFDictionary, nil) } #if os(iOS) var status = attempt(addAccessGroup: true) if status == -34018 { // Missing entitlement, retry without access group status = attempt(addAccessGroup: false) } #else let status = attempt(addAccessGroup: false) #endif // Classify the result let result = classifySaveStatus(status) // Log all outcomes consistently switch result { case .success: SecureLogger.debug("Keychain save succeeded for key: \(key)", category: .keychain) case .duplicateItem: SecureLogger.warning("Keychain save found duplicate for key: \(key)", category: .keychain) case .accessDenied: SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), context: "Keychain access denied for key: \(key)", category: .keychain) case .deviceLocked: SecureLogger.warning("Device locked during keychain save for key: \(key)", category: .keychain) case .storageFull: SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), context: "Keychain storage full for key: \(key)", category: .keychain) case .otherError(let code): SecureLogger.error(NSError(domain: "Keychain", code: Int(code)), context: "Keychain save failed for key: \(key)", category: .keychain) } // Retry transient errors with exponential backoff if result.isRecoverableError && retryCount > 0 { let delayMs = UInt32((3 - retryCount) * 100) // 100ms, 200ms backoff usleep(delayMs * 1000) SecureLogger.debug("Retrying keychain save for key: \(key), attempts remaining: \(retryCount)", category: .keychain) return saveDataWithResult(data, forKey: key, retryCount: retryCount - 1) } return result } /// Internal method to retrieve data with detailed result private func retrieveDataWithResult(forKey key: String) -> KeychainReadResult { let base: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecAttrService as String: service, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: AnyObject? func attempt(withAccessGroup: Bool) -> OSStatus { var q = base if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup } return SecItemCopyMatching(q as CFDictionary, &result) } #if os(iOS) var status = attempt(withAccessGroup: true) if status == -34018 { status = attempt(withAccessGroup: false) } #else let status = attempt(withAccessGroup: false) #endif // Classify the result let readResult = classifyReadStatus(status, data: result as? Data) // Log all outcomes consistently switch readResult { case .success: SecureLogger.debug("Keychain read succeeded for key: \(key)", category: .keychain) case .itemNotFound: // Expected case - no logging needed for missing keys break case .accessDenied: SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), context: "Keychain access denied for key: \(key)", category: .keychain) case .deviceLocked: SecureLogger.warning("Device locked during keychain read for key: \(key)", category: .keychain) case .authenticationFailed: SecureLogger.warning("Authentication failed for keychain read of key: \(key)", category: .keychain) case .otherError(let code): SecureLogger.error(NSError(domain: "Keychain", code: Int(code)), context: "Keychain read failed for key: \(key)", category: .keychain) } return readResult } /// Classify keychain read status into meaningful categories private func classifyReadStatus(_ status: OSStatus, data: Data?) -> KeychainReadResult { switch status { case errSecSuccess: if let data = data { return .success(data) } return .otherError(status) case errSecItemNotFound: return .itemNotFound case errSecInteractionNotAllowed: // Device is locked or in a state that doesn't allow keychain access return .deviceLocked case errSecAuthFailed: return .authenticationFailed case -34018: // errSecMissingEntitlement return .accessDenied case errSecNotAvailable: return .accessDenied default: return .otherError(status) } } /// Classify keychain save status into meaningful categories private func classifySaveStatus(_ status: OSStatus) -> KeychainSaveResult { switch status { case errSecSuccess: return .success case errSecDuplicateItem: return .duplicateItem case errSecInteractionNotAllowed: return .deviceLocked case -34018: // errSecMissingEntitlement return .accessDenied case errSecNotAvailable: return .accessDenied case errSecDiskFull: return .storageFull default: return .otherError(status) } } // MARK: - Generic Operations private func save(_ value: String, forKey key: String) -> Bool { guard let data = value.data(using: .utf8) else { return false } return saveData(data, forKey: key) } private func saveData(_ data: Data, forKey key: String) -> Bool { // Delete any existing item first to ensure clean state _ = delete(forKey: key) // Build base query var base: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecValueData as String: data, kSecAttrService as String: service, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, kSecAttrLabel as String: "bitchat-\(key)" ] #if os(macOS) base[kSecAttrSynchronizable as String] = false #endif // Try with access group where it is expected to work (iOS app builds) var triedWithoutGroup = false func attempt(addAccessGroup: Bool) -> OSStatus { var query = base if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup } return SecItemAdd(query as CFDictionary, nil) } #if os(iOS) var status = attempt(addAccessGroup: true) if status == -34018 { // Missing entitlement, retry without access group triedWithoutGroup = true status = attempt(addAccessGroup: false) } #else // On macOS dev/simulator default to no access group to avoid -34018 let status = attempt(addAccessGroup: false) #endif if status == errSecSuccess { return true } if status == -34018 && !triedWithoutGroup { SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain) } else if status != errSecDuplicateItem { SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: .keychain) } return false } private func retrieve(forKey key: String) -> String? { guard let data = retrieveData(forKey: key) else { return nil } return String(data: data, encoding: .utf8) } private func retrieveData(forKey key: String) -> Data? { // Base query let base: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecAttrService as String: service, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: AnyObject? func attempt(withAccessGroup: Bool) -> OSStatus { var q = base if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup } return SecItemCopyMatching(q as CFDictionary, &result) } #if os(iOS) var status = attempt(withAccessGroup: true) if status == -34018 { status = attempt(withAccessGroup: false) } #else let status = attempt(withAccessGroup: false) #endif if status == errSecSuccess { return result as? Data } if status == -34018 { SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain) } return nil } private func delete(forKey key: String) -> Bool { // Base delete query let base: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecAttrService as String: service ] func attempt(withAccessGroup: Bool) -> OSStatus { var q = base if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup } return SecItemDelete(q as CFDictionary) } #if os(iOS) var status = attempt(withAccessGroup: true) if status == -34018 { status = attempt(withAccessGroup: false) } #else let status = attempt(withAccessGroup: false) #endif return status == errSecSuccess || status == errSecItemNotFound } // MARK: - Cleanup func deleteAllPasswords() -> Bool { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword ] // Add service if not empty if !service.isEmpty { query[kSecAttrService as String] = service } let status = SecItemDelete(query as CFDictionary) return status == errSecSuccess || status == errSecItemNotFound } // Delete ALL keychain data for panic mode func deleteAllKeychainData() -> Bool { SecureLogger.warning("Panic mode - deleting all keychain data", category: .security) var totalDeleted = 0 // Search without service restriction to catch all items let searchQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecMatchLimit as String: kSecMatchLimitAll, kSecReturnAttributes as String: true ] var result: AnyObject? let searchStatus = SecItemCopyMatching(searchQuery as CFDictionary, &result) if searchStatus == errSecSuccess, let items = result as? [[String: Any]] { for item in items { var shouldDelete = false let account = item[kSecAttrAccount as String] as? String ?? "" let service = item[kSecAttrService as String] as? String ?? "" let accessGroup = item[kSecAttrAccessGroup as String] as? String // More precise deletion criteria: // 1. Check for our specific app group // 2. OR check for our exact service name // 3. OR check for known legacy service names if accessGroup == appGroup { shouldDelete = true } else if service == self.service { shouldDelete = true } else if [ "com.bitchat.passwords", "com.bitchat.deviceidentity", "com.bitchat.noise.identity", "chat.bitchat.passwords", "bitchat.keychain", "bitchat", "com.bitchat" ].contains(service) { shouldDelete = true } if shouldDelete { // Build delete query with all available attributes for precise deletion var deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword ] if !account.isEmpty { deleteQuery[kSecAttrAccount as String] = account } if !service.isEmpty { deleteQuery[kSecAttrService as String] = service } // Add access group if present if let accessGroup = item[kSecAttrAccessGroup as String] as? String, !accessGroup.isEmpty && accessGroup != "test" { deleteQuery[kSecAttrAccessGroup as String] = accessGroup } let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) if deleteStatus == errSecSuccess { totalDeleted += 1 SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain) } } } } // Also try to delete by known service names and app group // This catches any items that might have been missed above let knownServices = [ self.service, // Current service name "com.bitchat.passwords", "com.bitchat.deviceidentity", "com.bitchat.noise.identity", "chat.bitchat.passwords", "chat.bitchat.nostr", "bitchat.keychain", "bitchat", "com.bitchat" ] for serviceName in knownServices { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName ] let status = SecItemDelete(query as CFDictionary) if status == errSecSuccess { totalDeleted += 1 } } // Also delete by app group to ensure complete cleanup let groupQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccessGroup as String: appGroup ] let groupStatus = SecItemDelete(groupQuery as CFDictionary) if groupStatus == errSecSuccess { totalDeleted += 1 } SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain) return totalDeleted > 0 } // MARK: - Security Utilities /// Securely clear sensitive data from memory func secureClear(_ data: inout Data) { _ = data.withUnsafeMutableBytes { bytes in // Use volatile memset to prevent compiler optimization memset_s(bytes.baseAddress, bytes.count, 0, bytes.count) } data = Data() // Clear the data object } /// Securely clear sensitive string from memory func secureClear(_ string: inout String) { // Convert to mutable data and clear if var data = string.data(using: .utf8) { secureClear(&data) } string = "" // Clear the string object } // MARK: - Debug func verifyIdentityKeyExists() -> Bool { let key = "identity_noiseStaticKey" return retrieveData(forKey: key) != nil } // MARK: - Generic Data Storage (consolidated from KeychainHelper) /// Save data with a custom service name func save(key: String, data: Data, service customService: String, accessible: CFString?) { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: customService, kSecAttrAccount as String: key, kSecValueData as String: data ] if let accessible = accessible { query[kSecAttrAccessible as String] = accessible } SecItemDelete(query as CFDictionary) SecItemAdd(query as CFDictionary, nil) } /// Load data from a custom service func load(key: String, service customService: String) -> Data? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: customService, kSecAttrAccount as String: key, kSecReturnData as String: true ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess else { return nil } return result as? Data } /// Delete data from a custom service func delete(key: String, service customService: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: customService, kSecAttrAccount as String: key ] SecItemDelete(query as CFDictionary) } } ================================================ FILE: bitchat/Services/LocationNotesManager.swift ================================================ import BitLogger import Foundation /// Dependencies for location notes, allowing tests to stub relay/identity behavior. struct LocationNotesDependencies { typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String] typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void typealias Unsubscribe = @MainActor (_ id: String) -> Void typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void var relayLookup: RelayLookup var subscribe: Subscribe var unsubscribe: Unsubscribe var sendEvent: SendEvent var deriveIdentity: (_ geohash: String) throws -> NostrIdentity var now: () -> Date private static let idBridge = NostrIdentityBridge() static let live = LocationNotesDependencies( relayLookup: { geohash, count in GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count) }, subscribe: { filter, id, relays, handler, onEOSE in NostrRelayManager.shared.subscribe( filter: filter, id: id, relayUrls: relays, handler: handler, onEOSE: onEOSE ) }, unsubscribe: { id in NostrRelayManager.shared.unsubscribe(id: id) }, sendEvent: { event, relays in NostrRelayManager.shared.sendEvent(event, to: relays) }, deriveIdentity: { geohash in try idBridge.deriveIdentity(forGeohash: geohash) }, now: { Date() } ) } /// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8). /// Subscribes to and publishes notes for a given geohash and provides a send API. @MainActor final class LocationNotesManager: ObservableObject { enum State: Equatable { case idle case loading case ready case noRelays } struct Note: Identifiable, Equatable { let id: String let pubkey: String let content: String let createdAt: Date let nickname: String? var displayName: String { let suffix = String(pubkey.suffix(4)) if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "\(nick)#\(suffix)" } return "anon#\(suffix)" } } @Published private(set) var notes: [Note] = [] // reverse-chron sorted @Published private(set) var geohash: String @Published private(set) var initialLoadComplete: Bool = false @Published private(set) var state: State = .loading @Published private(set) var errorMessage: String? private var subscriptionID: String? private var noteIDs = Set() // O(1) duplicate detection private let dependencies: LocationNotesDependencies private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200) private enum Strings { static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location") static func failedToSend(_ detail: String) -> String { String( format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"), locale: .current, detail ) } } init(geohash: String, dependencies: LocationNotesDependencies = .live) { let norm = geohash.lowercased() self.geohash = norm self.dependencies = dependencies // Validate geohash (building-level precision: 8 chars) if !Geohash.isValidBuildingGeohash(norm) { SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session) } subscribe() } func setGeohash(_ newGeohash: String) { let norm = newGeohash.lowercased() guard norm != geohash else { return } // Validate geohash (building-level precision: 8 chars) guard Geohash.isValidBuildingGeohash(norm) else { SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session) return } if let sub = subscriptionID { dependencies.unsubscribe(sub) subscriptionID = nil } // Set loading state before clearing to prevent empty state flicker state = .loading initialLoadComplete = false errorMessage = nil geohash = norm notes.removeAll() noteIDs.removeAll() subscribe() } func refresh() { if let sub = subscriptionID { dependencies.unsubscribe(sub) subscriptionID = nil } // Set loading state before clearing to prevent empty state flicker state = .loading initialLoadComplete = false errorMessage = nil notes.removeAll() noteIDs.removeAll() subscribe() } func clearError() { errorMessage = nil } private func subscribe() { state = .loading errorMessage = nil if let sub = subscriptionID { dependencies.unsubscribe(sub) subscriptionID = nil } let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))" let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) guard !relays.isEmpty else { subscriptionID = nil initialLoadComplete = true state = .noRelays errorMessage = Strings.noRelays SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session) return } subscriptionID = subID initialLoadComplete = false // Subscribe to center + 8 neighbors (± 1 grid) let neighbors = Geohash.neighbors(of: geohash) let allGeohashes = [geohash] + neighbors let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200) // Build a set of valid geohashes for tag matching (includes all 9 cells) let validGeohashes = Set(allGeohashes.map { $0.lowercased() }) dependencies.subscribe(filter, subID, relays, { [weak self] event in guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } // Ensure matching tag - accept any of our 9 geohashes guard event.tags.contains(where: { tag in tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased()) }) else { return } guard !self.noteIDs.contains(event.id) else { return } self.noteIDs.insert(event.id) let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick) self.notes.append(note) self.notes.sort { $0.createdAt > $1.createdAt } self.enforceMemoryCap() self.state = .ready }, { [weak self] in guard let self = self else { return } self.initialLoadComplete = true if self.state != .noRelays { self.state = .ready } }) } /// Send a location note for the current geohash using the per-geohash identity. func send(content: String, nickname: String) { let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) guard !relays.isEmpty else { state = .noRelays errorMessage = Strings.noRelays SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session) return } do { let id = try dependencies.deriveIdentity(geohash) let event = try NostrProtocol.createGeohashTextNote( content: trimmed, geohash: geohash, senderIdentity: id, nickname: nickname ) dependencies.sendEvent(event, relays) // Optimistic local-echo let echo = Note( id: event.id, pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)), nickname: nickname ) self.noteIDs.insert(event.id) self.notes.insert(echo, at: 0) self.enforceMemoryCap() self.state = .ready self.errorMessage = nil } catch { SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session) errorMessage = Strings.failedToSend(error.localizedDescription) } } /// Enforces defensive memory cap on notes array (keeps newest). private func enforceMemoryCap() { if notes.count > maxNotesInMemory { let removed = notes.count - maxNotesInMemory notes = Array(notes.prefix(maxNotesInMemory)) SecureLogger.debug("LocationNotesManager: trimmed \(removed) old notes (cap: \(maxNotesInMemory))", category: .session) } } /// Explicitly cancel subscription and release resources. func cancel() { if let sub = subscriptionID { dependencies.unsubscribe(sub) subscriptionID = nil } state = .idle errorMessage = nil } } ================================================ FILE: bitchat/Services/LocationStateManager.swift ================================================ import BitLogger import Foundation import Combine #if os(iOS) || os(macOS) import CoreLocation protocol LocationStateManaging: AnyObject { var delegate: CLLocationManagerDelegate? { get set } var desiredAccuracy: CLLocationAccuracy { get set } var distanceFilter: CLLocationDistance { get set } var authorizationStatus: CLAuthorizationStatus { get } func requestWhenInUseAuthorization() func requestLocation() func startUpdatingLocation() func stopUpdatingLocation() } protocol LocationStateGeocoding: AnyObject { func cancelGeocode() func reverseGeocodeLocation( _ location: CLLocation, completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void ) } private final class CLLocationManagerAdapter: NSObject, LocationStateManaging { private let base = CLLocationManager() var delegate: CLLocationManagerDelegate? { get { base.delegate } set { base.delegate = newValue } } var desiredAccuracy: CLLocationAccuracy { get { base.desiredAccuracy } set { base.desiredAccuracy = newValue } } var distanceFilter: CLLocationDistance { get { base.distanceFilter } set { base.distanceFilter = newValue } } var authorizationStatus: CLAuthorizationStatus { base.authorizationStatus } func requestWhenInUseAuthorization() { base.requestWhenInUseAuthorization() } func requestLocation() { base.requestLocation() } func startUpdatingLocation() { base.startUpdatingLocation() } func stopUpdatingLocation() { base.stopUpdatingLocation() } } private final class CLGeocoderAdapter: LocationStateGeocoding { private let base = CLGeocoder() func cancelGeocode() { base.cancelGeocode() } func reverseGeocodeLocation( _ location: CLLocation, completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void ) { base.reverseGeocodeLocation(location, completionHandler: completionHandler) } } /// Unified manager for location-based channel state including: /// - CoreLocation permissions and one-shot location retrieval /// - Geohash channel computation from coordinates /// - Channel selection and teleport state /// - Bookmark persistence and friendly name resolution /// /// Consolidates LocationChannelManager + GeohashBookmarksStore into a single source of truth. final class LocationStateManager: NSObject, CLLocationManagerDelegate, ObservableObject { static let shared = LocationStateManager() // MARK: - Permission State enum PermissionState: Equatable { case notDetermined case denied case restricted case authorized } // MARK: - Private Properties (CoreLocation) private let cl: LocationStateManaging private let geocoder: LocationStateGeocoding private var lastLocation: CLLocation? private var refreshTimer: Timer? private var isGeocoding: Bool = false // MARK: - Persistence Keys private let selectedChannelKey = "locationChannel.selected" private let teleportedStoreKey = "locationChannel.teleportedSet" private let bookmarksKey = "locationChannel.bookmarks" private let bookmarkNamesKey = "locationChannel.bookmarkNames" // MARK: - Published State (Channel) @Published private(set) var permissionState: PermissionState = .notDetermined @Published private(set) var availableChannels: [GeohashChannel] = [] @Published private(set) var selectedChannel: ChannelID = .mesh @Published var teleported: Bool = false @Published private(set) var locationNames: [GeohashChannelLevel: String] = [:] // MARK: - Published State (Bookmarks) @Published private(set) var bookmarks: [String] = [] @Published private(set) var bookmarkNames: [String: String] = [:] // MARK: - Private State private var teleportedSet: Set = [] private var bookmarkMembership: Set = [] private var resolvingNames: Set = [] private let storage: UserDefaults /// Returns true if running in test environment private static var isRunningTests: Bool { let env = ProcessInfo.processInfo.environment return NSClassFromString("XCTestCase") != nil || env["XCTestConfigurationFilePath"] != nil || env["XCTestBundlePath"] != nil || env["GITHUB_ACTIONS"] != nil || env["CI"] != nil } // MARK: - Initialization private override init() { self.storage = .standard self.cl = CLLocationManagerAdapter() self.geocoder = CLGeocoderAdapter() super.init() // Skip CoreLocation setup in test environments guard !Self.isRunningTests else { loadPersistedState() return } cl.delegate = self cl.desiredAccuracy = kCLLocationAccuracyHundredMeters cl.distanceFilter = TransportConfig.locationDistanceFilterMeters loadPersistedState() initializePermissionState() } /// Internal initializer for testing with custom storage init(storage: UserDefaults) { self.storage = storage self.cl = CLLocationManagerAdapter() self.geocoder = CLGeocoderAdapter() super.init() loadPersistedState() } internal init( storage: UserDefaults, locationManager: LocationStateManaging, geocoder: LocationStateGeocoding, shouldInitializeCoreLocation: Bool ) { self.storage = storage self.cl = locationManager self.geocoder = geocoder super.init() loadPersistedState() guard shouldInitializeCoreLocation else { return } cl.delegate = self cl.desiredAccuracy = kCLLocationAccuracyHundredMeters cl.distanceFilter = TransportConfig.locationDistanceFilterMeters initializePermissionState() } private func loadPersistedState() { // Load selected channel if let data = storage.data(forKey: selectedChannelKey), let channel = try? JSONDecoder().decode(ChannelID.self, from: data) { selectedChannel = channel } // Load teleported set if let data = storage.data(forKey: teleportedStoreKey), let arr = try? JSONDecoder().decode([String].self, from: data) { teleportedSet = Set(arr) } // Load bookmarks if let data = storage.data(forKey: bookmarksKey), let arr = try? JSONDecoder().decode([String].self, from: data) { var seen = Set() var list: [String] = [] for raw in arr { let gh = Self.normalizeGeohash(raw) guard !gh.isEmpty, !seen.contains(gh) else { continue } seen.insert(gh) list.append(gh) } bookmarks = list bookmarkMembership = seen } // Load bookmark names if let data = storage.data(forKey: bookmarkNamesKey), let dict = try? JSONDecoder().decode([String: String].self, from: data) { bookmarkNames = dict } } private func initializePermissionState() { let status = cl.authorizationStatus updatePermissionState(from: status) // Fall back to persisted teleport state if no location authorization switch status { case .authorizedAlways, .authorizedWhenInUse, .authorized: break case .notDetermined, .restricted, .denied: fallthrough @unknown default: if case .location(let ch) = selectedChannel { teleported = teleportedSet.contains(ch.geohash) } } } // MARK: - Public API (Permissions & Location) func enableLocationChannels() { let status = cl.authorizationStatus switch status { case .notDetermined: cl.requestWhenInUseAuthorization() case .restricted: Task { @MainActor in self.permissionState = .restricted } case .denied: Task { @MainActor in self.permissionState = .denied } case .authorizedAlways, .authorizedWhenInUse, .authorized: Task { @MainActor in self.permissionState = .authorized } requestOneShotLocation() @unknown default: Task { @MainActor in self.permissionState = .restricted } } } func refreshChannels() { if permissionState == .authorized { requestOneShotLocation() } } func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) { guard permissionState == .authorized else { return } refreshTimer?.invalidate() refreshTimer = nil cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters cl.startUpdatingLocation() requestOneShotLocation() } func endLiveRefresh() { refreshTimer?.invalidate() refreshTimer = nil cl.stopUpdatingLocation() cl.desiredAccuracy = kCLLocationAccuracyHundredMeters cl.distanceFilter = TransportConfig.locationDistanceFilterMeters } // MARK: - Public API (Channel Selection) func select(_ channel: ChannelID) { Task { @MainActor in self.selectedChannel = channel if let data = try? JSONEncoder().encode(channel) { self.storage.set(data, forKey: self.selectedChannelKey) } switch channel { case .mesh: self.teleported = false case .location(let ch): let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash } if inRegional { self.teleported = false if self.teleportedSet.contains(ch.geohash) { self.teleportedSet.remove(ch.geohash) self.persistTeleportedSet() } } else { self.teleported = self.teleportedSet.contains(ch.geohash) } } } } func markTeleported(for geohash: String, _ flag: Bool) { if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) } persistTeleportedSet() if case .location(let ch) = selectedChannel, ch.geohash == geohash { Task { @MainActor in self.teleported = flag } } } // MARK: - Public API (Bookmarks) func isBookmarked(_ geohash: String) -> Bool { bookmarkMembership.contains(Self.normalizeGeohash(geohash)) } func toggleBookmark(_ geohash: String) { let gh = Self.normalizeGeohash(geohash) if bookmarkMembership.contains(gh) { removeBookmark(gh) } else { addBookmark(gh) } } func addBookmark(_ geohash: String) { let gh = Self.normalizeGeohash(geohash) guard !gh.isEmpty, !bookmarkMembership.contains(gh) else { return } bookmarks.insert(gh, at: 0) bookmarkMembership.insert(gh) persistBookmarks() resolveBookmarkNameIfNeeded(for: gh) } func removeBookmark(_ geohash: String) { let gh = Self.normalizeGeohash(geohash) guard bookmarkMembership.contains(gh) else { return } if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) } bookmarkMembership.remove(gh) if bookmarkNames.removeValue(forKey: gh) != nil { persistBookmarkNames() } persistBookmarks() } // MARK: - CLLocationManagerDelegate private func requestOneShotLocation() { cl.requestLocation() } func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { updatePermissionState(from: status) if case .authorized = permissionState { requestOneShotLocation() } } @available(iOS 14.0, macOS 11.0, *) func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { updatePermissionState(from: manager.authorizationStatus) if case .authorized = permissionState { requestOneShotLocation() } } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let loc = locations.last else { return } lastLocation = loc computeChannels(from: loc.coordinate) reverseGeocodeLocation(loc) } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { SecureLogger.error("LocationStateManager: location error: \(error.localizedDescription)", category: .session) } // MARK: - Private Helpers (Permission) private func updatePermissionState(from status: CLAuthorizationStatus) { let newState: PermissionState switch status { case .notDetermined: newState = .notDetermined case .restricted: newState = .restricted case .denied: newState = .denied case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized @unknown default: newState = .restricted } Task { @MainActor in self.permissionState = newState } } // MARK: - Private Helpers (Channel Computation) private func computeChannels(from coord: CLLocationCoordinate2D) { let levels = GeohashChannelLevel.allCases var result: [GeohashChannel] = [] for level in levels { let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision) result.append(GeohashChannel(level: level, geohash: gh)) } Task { @MainActor in self.availableChannels = result switch self.selectedChannel { case .mesh: self.teleported = false case .location(let ch): let inRegional = result.contains { $0.geohash == ch.geohash } if inRegional { self.teleported = false if self.teleportedSet.contains(ch.geohash) { self.teleportedSet.remove(ch.geohash) self.persistTeleportedSet() } } else { self.teleported = true } } } } // MARK: - Private Helpers (Geocoding) private func reverseGeocodeLocation(_ location: CLLocation) { geocoder.cancelGeocode() isGeocoding = true geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in guard let self = self else { return } self.isGeocoding = false if let pm = placemarks?.first { let names = self.locationNamesByLevel(from: pm) Task { @MainActor in self.locationNames = names } } } } private func locationNamesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] { var dict: [GeohashChannelLevel: String] = [:] if let country = pm.country, !country.isEmpty { dict[.region] = country } if let admin = pm.administrativeArea, !admin.isEmpty { dict[.province] = admin } else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty { dict[.province] = subAdmin } if let locality = pm.locality, !locality.isEmpty { dict[.city] = locality } else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty { dict[.city] = subAdmin } else if let admin = pm.administrativeArea, !admin.isEmpty { dict[.city] = admin } if let subLocality = pm.subLocality, !subLocality.isEmpty { dict[.neighborhood] = subLocality } else if let locality = pm.locality, !locality.isEmpty { dict[.neighborhood] = locality } if let subLocality = pm.subLocality, !subLocality.isEmpty { dict[.block] = subLocality } else if let locality = pm.locality, !locality.isEmpty { dict[.block] = locality } if let name = pm.name, !name.isEmpty { dict[.building] = name } else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty { dict[.building] = thoroughfare } return dict } func resolveBookmarkNameIfNeeded(for geohash: String) { let gh = Self.normalizeGeohash(geohash) guard !gh.isEmpty, bookmarkNames[gh] == nil, !resolvingNames.contains(gh) else { return } resolvingNames.insert(gh) if gh.count <= 2 { let b = Geohash.decodeBounds(gh) let pts: [CLLocation] = [ CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), CLLocation(latitude: b.latMin, longitude: b.lonMin), CLLocation(latitude: b.latMin, longitude: b.lonMax), CLLocation(latitude: b.latMax, longitude: b.lonMin), CLLocation(latitude: b.latMax, longitude: b.lonMax) ] resolveCompositeAdminName(geohash: gh, points: pts) } else { let center = Geohash.decodeCenter(gh) let loc = CLLocation(latitude: center.lat, longitude: center.lon) geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in guard let self = self else { return } defer { self.resolvingNames.remove(gh) } if let pm = placemarks?.first, let name = Self.nameForGeohashLength(gh.count, from: pm), !name.isEmpty { DispatchQueue.main.async { self.bookmarkNames[gh] = name self.persistBookmarkNames() } } } } } private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) { var uniqueAdmins: [String] = [] var seenAdmins = Set() var idx = 0 func step() { if idx >= points.count { let finalName: String? = { if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] } return uniqueAdmins.first }() if let finalName = finalName, !finalName.isEmpty { DispatchQueue.main.async { self.bookmarkNames[gh] = finalName self.persistBookmarkNames() } } self.resolvingNames.remove(gh) return } let loc = points[idx] idx += 1 geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in guard self != nil else { return } if let pm = placemarks?.first { if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) { seenAdmins.insert(admin) uniqueAdmins.append(admin) } else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) { seenAdmins.insert(country) uniqueAdmins.append(country) } } step() } } step() } private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? { switch len { case 0...2: return pm.administrativeArea ?? pm.country case 3...4: return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country case 5: return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea case 6...7: return pm.subLocality ?? pm.locality ?? pm.administrativeArea default: return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country } } // MARK: - Private Helpers (Persistence) private func persistTeleportedSet() { if let data = try? JSONEncoder().encode(Array(teleportedSet)) { storage.set(data, forKey: teleportedStoreKey) } } private func persistBookmarks() { if let data = try? JSONEncoder().encode(bookmarks) { storage.set(data, forKey: bookmarksKey) } } private func persistBookmarkNames() { if let data = try? JSONEncoder().encode(bookmarkNames) { storage.set(data, forKey: bookmarkNamesKey) } } private static func normalizeGeohash(_ s: String) -> String { let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") return s .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .replacingOccurrences(of: "#", with: "") .filter { allowed.contains($0) } } } // MARK: - Backward Compatibility Typealiases typealias LocationChannelManager = LocationStateManager typealias GeohashBookmarksStore = LocationStateManager // MARK: - Backward Compatibility Extensions extension LocationStateManager { /// Backward compatibility: toggle bookmark (was GeohashBookmarksStore.toggle) func toggle(_ geohash: String) { toggleBookmark(geohash) } /// Backward compatibility: add bookmark (was GeohashBookmarksStore.add) func add(_ geohash: String) { addBookmark(geohash) } /// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove) func remove(_ geohash: String) { removeBookmark(geohash) } } #endif ================================================ FILE: bitchat/Services/MeshTopologyTracker.swift ================================================ import Foundation /// Tracks observed mesh topology and computes hop-by-hop routes. final class MeshTopologyTracker { private typealias RoutingID = Data private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent) private let hopSize = 8 // Directed claims: Key claims to see Value (neighbors) private var claims: [RoutingID: Set] = [:] // Last time we received an update from a node private var lastSeen: [RoutingID: Date] = [:] // Maximum age for topology claims to be considered fresh for routing // Routes computed using stale topology can fail when the network has changed private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds func reset() { queue.sync(flags: .barrier) { self.claims.removeAll() self.lastSeen.removeAll() } } /// Update the topology with a node's self-reported neighbor list func updateNeighbors(for sourceData: Data?, neighbors: [Data]) { guard let source = sanitize(sourceData) else { return } // Sanitize neighbors and exclude self-loops let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source]) queue.sync(flags: .barrier) { self.claims[source] = validNeighbors self.lastSeen[source] = Date() } } func removePeer(_ data: Data?) { guard let peer = sanitize(data) else { return } queue.sync(flags: .barrier) { self.claims.removeValue(forKey: peer) self.lastSeen.removeValue(forKey: peer) } } /// Prune nodes that haven't updated their topology in `age` seconds func prune(olderThan age: TimeInterval) { let deadline = Date().addingTimeInterval(-age) queue.sync(flags: .barrier) { let stale = self.lastSeen.filter { $0.value < deadline } for (peer, _) in stale { self.claims.removeValue(forKey: peer) self.lastSeen.removeValue(forKey: peer) } } } func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? { guard let source = sanitize(start), let target = sanitize(goal) else { return nil } if source == target { return [] } // Direct connection, no intermediate hops return queue.sync { let now = Date() let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold) // BFS var visited: Set = [source] // Queue stores paths: [Start, Hop1, Hop2, ..., Current] var queuePaths: [[RoutingID]] = [[source]] while !queuePaths.isEmpty { let path = queuePaths.removeFirst() // Limit path length (path contains source + maxHops + target) -> maxHops intermediate // If maxHops = 10, max edges = 11, max nodes = 12. if path.count > maxHops + 1 { continue } guard let last = path.last else { continue } // Get neighbors that 'last' claims to see guard let neighbors = claims[last] else { continue } // Check if 'last' node's topology info is fresh guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else { continue // Skip stale nodes } for neighbor in neighbors { if visited.contains(neighbor) { continue } // CONFIRMED EDGE CHECK: // 'last' claims 'neighbor' (checked above) // Does 'neighbor' claim 'last'? guard let neighborClaims = claims[neighbor], neighborClaims.contains(last) else { continue } // Check if 'neighbor' node's topology info is fresh guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else { continue // Skip edges to stale nodes } var nextPath = path nextPath.append(neighbor) if neighbor == target { // Return only intermediate hops // Path: [Source, I1, I2, Target] -> [I1, I2] return Array(nextPath.dropFirst().dropLast()) } visited.insert(neighbor) queuePaths.append(nextPath) } } return nil } } // MARK: - Helpers private func sanitize(_ data: Data?) -> Data? { guard var value = data, !value.isEmpty else { return nil } if value.count > hopSize { value = Data(value.prefix(hopSize)) } else if value.count < hopSize { value.append(Data(repeating: 0, count: hopSize - value.count)) } return value } } ================================================ FILE: bitchat/Services/MessageDeduplicationService.swift ================================================ // // MessageDeduplicationService.swift // bitchat // // Handles message deduplication using LRU caches. // This is free and unencumbered software released into the public domain. // import Foundation // MARK: - LRU Deduplication Cache /// Generic LRU (Least Recently Used) cache for deduplication. /// Uses an efficient O(1) lookup with periodic compaction. /// Thread-safe via @MainActor - all callers are already on main actor. @MainActor final class LRUDeduplicationCache { private var map: [String: Value] = [:] private var order: [String] = [] private var head: Int = 0 private let capacity: Int /// Creates a new LRU cache with the specified capacity. /// - Parameter capacity: Maximum number of entries before eviction init(capacity: Int) { precondition(capacity > 0, "LRU cache capacity must be positive") self.capacity = capacity } /// Number of active entries in the cache var count: Int { order.count - head } /// Checks if a key exists in the cache func contains(_ key: String) -> Bool { map[key] != nil } /// Gets the value for a key, or nil if not present func value(for key: String) -> Value? { map[key] } /// Records a key-value pair, updating if exists or inserting if new func record(_ key: String, value: Value) { if map[key] == nil { order.append(key) } map[key] = value trimIfNeeded() } /// Removes a specific key from the cache func remove(_ key: String) { map.removeValue(forKey: key) // Note: key remains in order array but will be skipped during eviction } /// Clears all entries from the cache func clear() { map.removeAll() order.removeAll() head = 0 } // MARK: - Private private func trimIfNeeded() { let activeCount = order.count - head guard activeCount > capacity else { return } let overflow = activeCount - capacity for _ in 0.. String? { // Skip keys that were already removed from map while head < order.count { let key = order[head] head += 1 // Periodically compact the backing storage if head >= 32 && head * 2 >= order.count { order.removeFirst(head) head = 0 } // Only return if key is still in map if map[key] != nil { return key } } return nil } } // MARK: - Content Normalizer /// Normalizes message content for near-duplicate detection. enum ContentNormalizer { /// Regex to simplify HTTP URLs by stripping query strings and fragments private static let simplifyHTTPURL: NSRegularExpression = { try! NSRegularExpression( pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive] ) }() /// Normalizes content for deduplication comparison. /// - Parameters: /// - content: The raw message content /// - prefixLength: Maximum characters to consider (default from TransportConfig) /// - Returns: A hash-based key for comparison static func normalizedKey( _ content: String, prefixLength: Int = TransportConfig.contentKeyPrefixLength ) -> String { // Lowercase for case-insensitive comparison let lowered = content.lowercased() let ns = lowered as NSString let range = NSRange(location: 0, length: ns.length) // Simplify URLs by stripping query/fragment var simplified = "" var last = 0 for match in simplifyHTTPURL.matches(in: lowered, options: [], range: range) { if match.range.location > last { simplified += ns.substring(with: NSRange(location: last, length: match.range.location - last)) } let url = ns.substring(with: match.range) if let queryIndex = url.firstIndex(where: { $0 == "?" || $0 == "#" }) { simplified += String(url[.. /// Cache for Nostr event ID deduplication private let nostrEventCache: LRUDeduplicationCache /// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format) private let nostrAckCache: LRUDeduplicationCache /// Creates a new deduplication service with specified capacities. /// - Parameters: /// - contentCapacity: Max entries for content cache /// - nostrEventCapacity: Max entries for Nostr event cache init( contentCapacity: Int = TransportConfig.contentLRUCap, nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap ) { self.contentCache = LRUDeduplicationCache(capacity: contentCapacity) self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity) self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity) } // MARK: - Content Deduplication /// Records content with its timestamp for near-duplicate detection. /// - Parameters: /// - content: The message content /// - timestamp: When the content was received func recordContent(_ content: String, timestamp: Date) { let key = ContentNormalizer.normalizedKey(content) contentCache.record(key, value: timestamp) } /// Records a pre-normalized content key with its timestamp. /// - Parameters: /// - key: The normalized content key /// - timestamp: When the content was received func recordContentKey(_ key: String, timestamp: Date) { contentCache.record(key, value: timestamp) } /// Gets the timestamp for previously seen content. /// - Parameter content: The message content /// - Returns: The timestamp when first seen, or nil if not seen func contentTimestamp(for content: String) -> Date? { let key = ContentNormalizer.normalizedKey(content) return contentCache.value(for: key) } /// Gets the timestamp for a pre-normalized content key. /// - Parameter key: The normalized content key /// - Returns: The timestamp when first seen, or nil if not seen func contentTimestamp(forKey key: String) -> Date? { contentCache.value(for: key) } /// Normalizes content to a deduplication key. /// - Parameter content: The raw content /// - Returns: A normalized hash key func normalizedContentKey(_ content: String) -> String { ContentNormalizer.normalizedKey(content) } // MARK: - Nostr Event Deduplication /// Checks if a Nostr event has already been processed. /// - Parameter eventId: The event ID /// - Returns: true if already processed func hasProcessedNostrEvent(_ eventId: String) -> Bool { nostrEventCache.contains(eventId) } /// Records a Nostr event as processed. /// - Parameter eventId: The event ID func recordNostrEvent(_ eventId: String) { nostrEventCache.record(eventId, value: true) } // MARK: - Nostr ACK Deduplication /// Checks if a Nostr ACK has already been processed. /// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey" /// - Returns: true if already processed func hasProcessedNostrAck(_ ackKey: String) -> Bool { nostrAckCache.contains(ackKey) } /// Records a Nostr ACK as processed. /// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey" func recordNostrAck(_ ackKey: String) { nostrAckCache.record(ackKey, value: true) } /// Creates an ACK key from components. static func ackKey(messageId: String, ackType: String, senderPubkey: String) -> String { "\(messageId):\(ackType):\(senderPubkey)" } // MARK: - Clear /// Clears all caches func clearAll() { contentCache.clear() nostrEventCache.clear() nostrAckCache.clear() } /// Clears only the Nostr caches (events and ACKs) func clearNostrCaches() { nostrEventCache.clear() nostrAckCache.clear() } } ================================================ FILE: bitchat/Services/MessageFormattingEngine.swift ================================================ // // MessageFormattingEngine.swift // bitchat // // Handles message text formatting, including mentions, hashtags, URLs, and tokens. // This is free and unencumbered software released into the public domain. // import Foundation import SwiftUI // MARK: - Formatting Context Protocol /// Protocol defining the context needed for message formatting. /// Implemented by ChatViewModel to provide runtime state. @MainActor protocol MessageFormattingContext: AnyObject { /// The user's current nickname var nickname: String { get } /// Determines if a message was sent by the current user func isSelfMessage(_ message: BitchatMessage) -> Bool /// Gets the color for a message's sender func senderColor(for message: BitchatMessage, isDark: Bool) -> Color /// Resolves a peer ID to a clickable URL func peerURL(for peerID: PeerID) -> URL? } // MARK: - Formatting Engine /// Handles rich text formatting for chat messages. /// Extracts mentions, hashtags, URLs, Lightning invoices, and Cashu tokens. final class MessageFormattingEngine { // MARK: - Precompiled Regexes /// Precompiled regex patterns for message content parsing enum Patterns { static let hashtag: NSRegularExpression = { try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: []) }() static let mention: NSRegularExpression = { try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: []) }() static let cashu: NSRegularExpression = { try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: []) }() static let bolt11: NSRegularExpression = { try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: []) }() static let lnurl: NSRegularExpression = { try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: []) }() static let lightningScheme: NSRegularExpression = { try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: []) }() static let linkDetector: NSDataDetector? = { try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) }() static let quickCashuPresence: NSRegularExpression = { try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: []) }() static let simplifyHTTPURL: NSRegularExpression = { try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive]) }() } // MARK: - Match Types /// Types of matches found in message content enum MatchType: String { case hashtag case mention case url case cashu case lightning case bolt11 case lnurl } /// A match found in message content struct ContentMatch { let range: NSRange let type: MatchType } // MARK: - Public API /// Formats a message with rich text styling @MainActor static func formatMessage( _ message: BitchatMessage, context: MessageFormattingContext, colorScheme: ColorScheme ) -> AttributedString { let isDark = colorScheme == .dark let isSelf = context.isSelfMessage(message) // Check cache first if let cached = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) { return cached } var result = AttributedString() let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark) // Format system messages differently if message.sender == "system" { result = formatSystemMessage(message, isDark: isDark) } else { // Format sender header result = formatSenderHeader( message: message, baseColor: baseColor, isSelf: isSelf, context: context ) // Format content let contentResult = formatContent( message.content, baseColor: baseColor, isSelf: isSelf, isMentioned: message.mentions?.contains(context.nickname) ?? false ) result.append(contentResult) // Add timestamp result.append(formatTimestamp(message.formattedTimestamp)) } // Cache the result message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf) return result } /// Formats just the message header (sender portion) @MainActor static func formatHeader( _ message: BitchatMessage, context: MessageFormattingContext, colorScheme: ColorScheme ) -> AttributedString { let isDark = colorScheme == .dark let isSelf = context.isSelfMessage(message) let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark) if message.sender == "system" { var style = AttributeContainer() style.foregroundColor = baseColor style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) return AttributedString(message.sender).mergingAttributes(style) } return formatSenderHeader( message: message, baseColor: baseColor, isSelf: isSelf, context: context ) } /// Extracts mentions from message content static func extractMentions(from content: String) -> [String] { let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = Patterns.mention.matches(in: content, options: [], range: range) return matches.compactMap { match -> String? in guard match.numberOfRanges > 1 else { return nil } let captureRange = match.range(at: 1) guard let swiftRange = Range(captureRange, in: content) else { return nil } return String(content[swiftRange]) } } /// Checks if content contains a Cashu token static func containsCashuToken(_ content: String) -> Bool { let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) return Patterns.quickCashuPresence.numberOfMatches(in: content, options: [], range: range) > 0 } // MARK: - Private Helpers private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString { var result = AttributedString() let content = AttributedString("* \(message.content) *") var contentStyle = AttributeContainer() contentStyle.foregroundColor = Color.gray contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic() result.append(content.mergingAttributes(contentStyle)) // Add timestamp let timestamp = AttributedString(" [\(message.formattedTimestamp)]") var timestampStyle = AttributeContainer() timestampStyle.foregroundColor = Color.gray.opacity(0.5) timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced) result.append(timestamp.mergingAttributes(timestampStyle)) return result } @MainActor private static func formatSenderHeader( message: BitchatMessage, baseColor: Color, isSelf: Bool, context: MessageFormattingContext ) -> AttributedString { var result = AttributedString() let (baseName, suffix) = message.sender.splitSuffix() var senderStyle = AttributeContainer() senderStyle.foregroundColor = baseColor let fontWeight: Font.Weight = isSelf ? .bold : .medium senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced) // Make sender clickable if let spid = message.senderPeerID, let url = context.peerURL(for: spid) { senderStyle.link = url } // Build: "<@baseName#suffix> " result.append(AttributedString("<@").mergingAttributes(senderStyle)) result.append(AttributedString(baseName).mergingAttributes(senderStyle)) if !suffix.isEmpty { var suffixStyle = senderStyle suffixStyle.foregroundColor = baseColor.opacity(0.6) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } result.append(AttributedString("> ").mergingAttributes(senderStyle)) return result } private static func formatContent( _ content: String, baseColor: Color, isSelf: Bool, isMentioned: Bool ) -> AttributedString { // For very long content without special tokens, use plain formatting let containsCashu = containsCashuToken(content) if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu { return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf) } // Find all matches let matches = findAllMatches(in: content) // Build formatted content var result = AttributedString() var lastEnd = content.startIndex for match in matches { guard let swiftRange = Range(match.range, in: content) else { continue } // Add text before match if lastEnd < swiftRange.lowerBound { let beforeText = String(content[lastEnd.. [ContentMatch] { let nsContent = content as NSString let nsLen = nsContent.length let fullRange = NSRange(location: 0, length: nsLen) // Quick hints to avoid unnecessary regex work let hasMentions = content.contains("@") let hasHashtags = content.contains("#") let hasURLs = content.contains("://") || content.contains("www.") || content.contains("http") let hasLightning = content.lowercased().contains("ln") || content.lowercased().contains("lightning:") let hasCashu = content.lowercased().contains("cashu") // Collect matches let mentionMatches = hasMentions ? Patterns.mention.matches(in: content, options: [], range: fullRange) : [] let hashtagMatches = hasHashtags ? Patterns.hashtag.matches(in: content, options: [], range: fullRange) : [] let urlMatches = hasURLs ? (Patterns.linkDetector?.matches(in: content, options: [], range: fullRange) ?? []) : [] let cashuMatches = hasCashu ? Patterns.cashu.matches(in: content, options: [], range: fullRange) : [] let lightningMatches = hasLightning ? Patterns.lightningScheme.matches(in: content, options: [], range: fullRange) : [] let bolt11Matches = hasLightning ? Patterns.bolt11.matches(in: content, options: [], range: fullRange) : [] let lnurlMatches = hasLightning ? Patterns.lnurl.matches(in: content, options: [], range: fullRange) : [] // Build mention ranges for overlap checking let mentionRanges = mentionMatches.map { $0.range(at: 0) } func overlapsMention(_ r: NSRange) -> Bool { mentionRanges.contains { NSIntersectionRange(r, $0).length > 0 } } func isStandaloneHashtag(_ r: NSRange) -> Bool { guard let swiftRange = Range(r, in: content) else { return false } if swiftRange.lowerBound == content.startIndex { return true } let prev = content.index(before: swiftRange.lowerBound) return content[prev].isWhitespace || content[prev].isNewline } func attachedToMention(_ r: NSRange) -> Bool { guard let swiftRange = Range(r, in: content), swiftRange.lowerBound > content.startIndex else { return false } var i = content.index(before: swiftRange.lowerBound) while true { let ch = content[i] if ch.isWhitespace || ch.isNewline { break } if ch == "@" { return true } if i == content.startIndex { break } i = content.index(before: i) } return false } var allMatches: [ContentMatch] = [] // Add hashtags (excluding those attached to mentions) for match in hashtagMatches { let range = match.range(at: 0) if !overlapsMention(range) && !attachedToMention(range) && isStandaloneHashtag(range) { allMatches.append(ContentMatch(range: range, type: .hashtag)) } } // Add mentions for match in mentionMatches { allMatches.append(ContentMatch(range: match.range(at: 0), type: .mention)) } // Add URLs for match in urlMatches where !overlapsMention(match.range) { allMatches.append(ContentMatch(range: match.range, type: .url)) } // Add Cashu tokens for match in cashuMatches where !overlapsMention(match.range(at: 0)) { allMatches.append(ContentMatch(range: match.range(at: 0), type: .cashu)) } // Add Lightning scheme URLs for match in lightningMatches where !overlapsMention(match.range(at: 0)) { allMatches.append(ContentMatch(range: match.range(at: 0), type: .lightning)) } // Add bolt11/lnurl (avoiding overlaps with lightning scheme and URLs) let occupied = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) } func overlapsOccupied(_ r: NSRange) -> Bool { occupied.contains { NSIntersectionRange(r, $0).length > 0 } } for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { allMatches.append(ContentMatch(range: match.range(at: 0), type: .bolt11)) } for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl)) } // Sort by position return allMatches.sorted { $0.range.location < $1.range.location } } private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString { var style = AttributeContainer() style.foregroundColor = baseColor style.font = isSelf ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) : .bitchatSystem(size: 14, design: .monospaced) return AttributedString(content).mergingAttributes(style) } private static func formatPlainText(_ text: String, baseColor: Color, isSelf: Bool, isMentioned: Bool) -> AttributedString { guard !text.isEmpty else { return AttributedString() } var style = AttributeContainer() style.foregroundColor = baseColor style.font = isSelf ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) : .bitchatSystem(size: 14, design: .monospaced) if isMentioned { style.font = style.font?.bold() } return AttributedString(text).mergingAttributes(style) } private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString { var style = AttributeContainer() switch type { case .mention: // Split optional '#abcd' suffix let (baseName, suffix) = text.splitSuffix() var result = AttributedString() var mentionStyle = AttributeContainer() mentionStyle.foregroundColor = .blue mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced) result.append(AttributedString(baseName).mergingAttributes(mentionStyle)) if !suffix.isEmpty { var suffixStyle = mentionStyle suffixStyle.foregroundColor = Color.gray.opacity(0.7) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } return result case .hashtag: style.foregroundColor = .purple style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) case .url: style.foregroundColor = .blue style.font = .bitchatSystem(size: 14, design: .monospaced) style.underlineStyle = .single if let url = URL(string: text) { style.link = url } case .cashu: style.foregroundColor = .green style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) style.backgroundColor = Color.green.opacity(0.1) case .lightning, .bolt11, .lnurl: style.foregroundColor = .yellow style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) style.backgroundColor = Color.yellow.opacity(0.1) } return AttributedString(text).mergingAttributes(style) } private static func formatTimestamp(_ timestamp: String) -> AttributedString { let text = AttributedString(" [\(timestamp)]") var style = AttributeContainer() style.foregroundColor = Color.gray.opacity(0.5) style.font = .bitchatSystem(size: 10, design: .monospaced) return text.mergingAttributes(style) } } ================================================ FILE: bitchat/Services/MessageRouter.swift ================================================ import BitLogger import Foundation /// Routes messages using available transports (Mesh, Nostr, etc.) @MainActor final class MessageRouter { private let transports: [Transport] // Outbox entry with timestamp for TTL-based eviction private struct QueuedMessage { let content: String let nickname: String let messageID: String let timestamp: Date } private var outbox: [PeerID: [QueuedMessage]] = [:] // Outbox limits to prevent unbounded memory growth private static let maxMessagesPerPeer = 100 private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours init(transports: [Transport]) { self.transports = transports // Observe favorites changes to learn Nostr mapping and flush queued messages NotificationCenter.default.addObserver( forName: .favoriteStatusChanged, object: nil, queue: .main ) { [weak self] note in guard let self = self else { return } if let data = note.userInfo?["peerPublicKey"] as? Data { let peerID = PeerID(publicKey: data) Task { @MainActor in self.flushOutbox(for: peerID) } } // Handle key updates if let newKey = note.userInfo?["peerPublicKey"] as? Data, let _ = note.userInfo?["isKeyUpdate"] as? Bool { let peerID = PeerID(publicKey: newKey) Task { @MainActor in self.flushOutbox(for: peerID) } } } } // MARK: - Transport Selection private func reachableTransport(for peerID: PeerID) -> Transport? { transports.first { $0.isPeerReachable(peerID) } } private func connectedTransport(for peerID: PeerID) -> Transport? { transports.first { $0.isPeerConnected(peerID) } } // MARK: - Message Sending func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { if let transport = reachableTransport(for: peerID) { SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) } else { // Queue for later with timestamp for TTL tracking if outbox[peerID] == nil { outbox[peerID] = [] } let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date()) outbox[peerID]?.append(message) // Enforce per-peer size limit with FIFO eviction if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer { let evicted = outbox[peerID]?.removeFirst() SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")…", category: .session) } SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session) } } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { if let transport = reachableTransport(for: peerID) { SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session) transport.sendReadReceipt(receipt, to: peerID) } else if !transports.isEmpty { SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session) } } func sendDeliveryAck(_ messageID: String, to peerID: PeerID) { if let transport = reachableTransport(for: peerID) { SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) transport.sendDeliveryAck(for: messageID, to: peerID) } } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { if let transport = connectedTransport(for: peerID) { transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) } else if let transport = reachableTransport(for: peerID) { transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) } } // MARK: - Outbox Management func flushOutbox(for peerID: PeerID) { guard let queued = outbox[peerID], !queued.isEmpty else { return } SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) let now = Date() var remaining: [QueuedMessage] = [] for message in queued { // Skip expired messages (TTL exceeded) if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds { SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session) continue } if let transport = reachableTransport(for: peerID) { SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) } else { remaining.append(message) } } if remaining.isEmpty { outbox.removeValue(forKey: peerID) } else { outbox[peerID] = remaining } } func flushAllOutbox() { for key in Array(outbox.keys) { flushOutbox(for: key) } } /// Periodically clean up expired messages from all outboxes func cleanupExpiredMessages() { let now = Date() for peerID in Array(outbox.keys) { outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds } if outbox[peerID]?.isEmpty == true { outbox.removeValue(forKey: peerID) } } } } ================================================ FILE: bitchat/Services/NetworkActivationService.swift ================================================ import Foundation import BitLogger import Combine import Tor @MainActor protocol NetworkActivationTorControlling: AnyObject { func setAutoStartAllowed(_ allowed: Bool) func startIfNeeded() func shutdownCompletely() } @MainActor protocol NetworkActivationRelayControlling: AnyObject { func connect() func disconnect() } protocol NetworkActivationProxyControlling: AnyObject { func setProxyMode(useTor: Bool) } extension TorManager: NetworkActivationTorControlling {} extension NostrRelayManager: NetworkActivationRelayControlling {} extension TorURLSession: NetworkActivationProxyControlling {} /// Coordinates when the app is allowed to start Tor and connect to Nostr relays. /// Policy: permit start when either location permissions are authorized OR /// there exists at least one mutual favorite. Otherwise, do not start. @MainActor final class NetworkActivationService: ObservableObject { static let shared = NetworkActivationService() @Published private(set) var activationAllowed: Bool = false @Published private(set) var userTorEnabled: Bool = true private var cancellables = Set() private var started = false private let torPreferenceKey = "networkActivationService.userTorEnabled" private var torAutoStartDesired: Bool = false private let storage: UserDefaults private let locationPermissionPublisher: AnyPublisher private let mutualFavoritesPublisher: AnyPublisher, Never> private let permissionProvider: () -> LocationChannelManager.PermissionState private let mutualFavoritesProvider: () -> Set private let torController: NetworkActivationTorControlling private let relayController: NetworkActivationRelayControlling private let proxyController: NetworkActivationProxyControlling private let notificationCenter: NotificationCenter private init() { storage = .standard locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher() mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher() permissionProvider = { LocationChannelManager.shared.permissionState } mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites } torController = TorManager.shared relayController = NostrRelayManager.shared proxyController = TorURLSession.shared notificationCenter = .default } internal init( storage: UserDefaults, locationPermissionPublisher: AnyPublisher, mutualFavoritesPublisher: AnyPublisher, Never>, permissionProvider: @escaping () -> LocationChannelManager.PermissionState, mutualFavoritesProvider: @escaping () -> Set, torController: NetworkActivationTorControlling, relayController: NetworkActivationRelayControlling, proxyController: NetworkActivationProxyControlling, notificationCenter: NotificationCenter = .default ) { self.storage = storage self.locationPermissionPublisher = locationPermissionPublisher self.mutualFavoritesPublisher = mutualFavoritesPublisher self.permissionProvider = permissionProvider self.mutualFavoritesProvider = mutualFavoritesProvider self.torController = torController self.relayController = relayController self.proxyController = proxyController self.notificationCenter = notificationCenter } func start() { guard !started else { return } started = true if let stored = storage.object(forKey: torPreferenceKey) as? Bool { userTorEnabled = stored } else { userTorEnabled = true } // Initial compute let allowed = basePolicyAllowed() activationAllowed = allowed torAutoStartDesired = allowed && userTorEnabled torController.setAutoStartAllowed(torAutoStartDesired) applyTorState(torDesired: torAutoStartDesired) if allowed { relayController.connect() } else { relayController.disconnect() } // React to location permission changes locationPermissionPublisher .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.reevaluate() } .store(in: &cancellables) // React to mutual favorites changes mutualFavoritesPublisher .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.reevaluate() } .store(in: &cancellables) } func setUserTorEnabled(_ enabled: Bool) { guard enabled != userTorEnabled else { return } userTorEnabled = enabled storage.set(enabled, forKey: torPreferenceKey) notificationCenter.post( name: .TorUserPreferenceChanged, object: nil, userInfo: ["enabled": enabled] ) reevaluate() } private func reevaluate() { let allowed = basePolicyAllowed() let torDesired = allowed && userTorEnabled let statusChanged = allowed != activationAllowed let torChanged = torDesired != torAutoStartDesired if statusChanged { SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session) activationAllowed = allowed } if statusChanged || torChanged { torAutoStartDesired = torDesired torController.setAutoStartAllowed(torDesired) applyTorState(torDesired: torDesired) } if allowed { if torChanged { // Reset relay sockets when switching transport path (Tor ↔︎ direct) relayController.disconnect() } relayController.connect() } else if statusChanged { relayController.disconnect() } } private func basePolicyAllowed() -> Bool { let permOK = permissionProvider() == .authorized let hasMutual = !mutualFavoritesProvider().isEmpty return permOK || hasMutual } private func applyTorState(torDesired: Bool) { proxyController.setProxyMode(useTor: torDesired) if torDesired { torController.startIfNeeded() } else { torController.shutdownCompletely() } } } ================================================ FILE: bitchat/Services/NoiseEncryptionService.swift ================================================ // // NoiseEncryptionService.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // /// /// # NoiseEncryptionService /// /// 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 (BLEService) and the cryptographic layer (NoiseProtocol). /// /// ## Overview /// This service provides a simplified API for establishing and managing encrypted /// channels between peers. It handles: /// - Static identity key management /// - Session lifecycle (creation, maintenance, teardown) /// - Message encryption/decryption /// - Peer authentication and fingerprint tracking /// - Automatic rekeying for forward secrecy /// /// ## Architecture /// The service operates at multiple levels: /// 1. **Identity Management**: Persistent Curve25519 keys stored in Keychain /// 2. **Session Management**: Per-peer Noise sessions with state tracking /// 3. **Message Processing**: Encryption/decryption with proper framing /// 4. **Security Features**: Rate limiting, fingerprint verification /// /// ## Key Features /// /// ### Identity Keys /// - Static Curve25519 key pair for Noise XX pattern /// - Ed25519 signing key pair for additional authentication /// - Keys persisted securely in iOS/macOS Keychain /// - Fingerprints derived from SHA256 of public keys /// /// ### Session Management /// - Lazy session creation (on-demand when sending messages) /// - Automatic session recovery after disconnections /// - Configurable rekey intervals for forward secrecy /// - Graceful handling of simultaneous handshakes /// /// ### Security Properties /// - Forward secrecy via ephemeral keys in handshakes /// - Mutual authentication via static key exchange /// - Protection against replay attacks /// - Rate limiting to prevent DoS attacks /// /// ## Encryption Flow /// ``` /// 1. Message arrives for encryption /// 2. Check if session exists for peer /// 3. If not, initiate Noise handshake /// 4. Once established, encrypt message /// 5. Add message type header for protocol handling /// 6. Return encrypted payload for transmission /// ``` /// /// ## Integration Points /// - **BLEService**: Calls this service for all private messages /// - **ChatViewModel**: Monitors encryption status for UI indicators /// - **KeychainManager**: Secure storage for identity keys /// /// ## Thread Safety /// - Concurrent read access via reader-writer queue /// - Session operations protected by per-peer queues /// - Atomic updates for critical state changes /// /// ## Error Handling /// - Graceful fallback for encryption failures /// - Clear error messages for debugging /// - Automatic retry with exponential backoff /// - User notification for critical failures /// /// ## Performance Considerations /// - Sessions cached in memory for fast access /// - Minimal allocations in hot paths /// - Efficient binary message format /// - Background queue for CPU-intensive operations /// import BitLogger import Foundation import CryptoKit // MARK: - Encryption Status /// Represents the current encryption status of a peer connection. /// Used for UI indicators and decision-making about message handling. enum EncryptionStatus: Equatable { case none // Failed or incompatible case noHandshake // No handshake attempted yet case noiseHandshaking // Currently establishing case noiseSecured // Established but not verified case noiseVerified // Established and verified var icon: String? { // Made optional to hide icon when no handshake switch self { case .none: return "lock.slash" // Failed handshake case .noHandshake: return nil // No icon when no handshake attempted case .noiseHandshaking: return "lock.rotation" case .noiseSecured: return "lock.fill" // Changed from "lock" to "lock.fill" for filled lock case .noiseVerified: return "checkmark.seal.fill" // Verified badge } } var description: String { switch self { case .none: return String(localized: "encryption.status.failed", comment: "Status text when encryption failed") case .noHandshake: return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened") case .noiseHandshaking: return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established") case .noiseSecured: return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified") case .noiseVerified: return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified") } } var accessibilityDescription: String { switch self { case .none: return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed") case .noHandshake: return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established") case .noiseHandshaking: return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established") case .noiseSecured: return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured") case .noiseVerified: return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified") } } } // MARK: - Noise Encryption Service /// Manages end-to-end encryption for BitChat using the Noise Protocol Framework. /// Provides a high-level API for establishing secure channels between peers, /// handling all cryptographic operations transparently. /// - Important: This service maintains the device's cryptographic identity final class NoiseEncryptionService { // Static identity key (persistent across sessions) private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey // Ed25519 signing key (persistent across sessions) private let signingKey: Curve25519.Signing.PrivateKey public let signingPublicKey: Curve25519.Signing.PublicKey // Session manager private let sessionManager: NoiseSessionManager // Peer fingerprints (SHA256 hash of static public key) private var peerFingerprints: [PeerID: String] = [:] private var fingerprintToPeerID: [String: PeerID] = [:] // Thread safety private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent) // Security components private let rateLimiter = NoiseRateLimiter() private let keychain: KeychainManagerProtocol // Session maintenance private var rekeyTimer: Timer? private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute // Callbacks private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake // Add a handler for peer authentication func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, 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: ((PeerID, String) -> Void)? { get { nil } // Always return nil for backward compatibility set { if let handler = newValue { addOnPeerAuthenticatedHandler(handler) } } } init(keychain: KeychainManagerProtocol) { self.keychain = keychain // BCH-01-009: Load or create static identity key with proper error handling let loadedKey: Curve25519.KeyAgreement.PrivateKey // Try to load from keychain with proper error classification let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") switch noiseKeyResult { case .success(let identityData): if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) { loadedKey = key SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true) } else { // Data corrupted, regenerate SecureLogger.warning("Noise static key data corrupted, regenerating", category: .keychain) loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain) } case .itemNotFound: // Expected case: no key exists yet, create new one loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain) case .accessDenied: // Critical error - log but proceed with ephemeral key (will be lost on restart) SecureLogger.error(NSError(domain: "Keychain", code: -1), context: "Keychain access denied - using ephemeral identity", category: .keychain) loadedKey = Curve25519.KeyAgreement.PrivateKey() case .deviceLocked, .authenticationFailed: // Recoverable error - use ephemeral key and warn SecureLogger.warning("Device locked or auth failed - using ephemeral identity until unlocked", category: .keychain) loadedKey = Curve25519.KeyAgreement.PrivateKey() case .otherError(let status): // Unexpected error - log and use ephemeral key SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), context: "Unexpected keychain error - using ephemeral identity", category: .keychain) loadedKey = Curve25519.KeyAgreement.PrivateKey() } // Now assign the final value self.staticIdentityKey = loadedKey self.staticIdentityPublicKey = staticIdentityKey.publicKey // BCH-01-009: Load or create signing key pair with proper error handling let loadedSigningKey: Curve25519.Signing.PrivateKey let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey") switch signingKeyResult { case .success(let signingData): if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) { loadedSigningKey = key SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true) } else { // Data corrupted, regenerate SecureLogger.warning("Ed25519 signing key data corrupted, regenerating", category: .keychain) loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain) } case .itemNotFound: // Expected case: no key exists yet, create new one loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain) case .accessDenied: // Critical error - log but proceed with ephemeral key SecureLogger.error(NSError(domain: "Keychain", code: -1), context: "Keychain access denied - using ephemeral signing key", category: .keychain) loadedSigningKey = Curve25519.Signing.PrivateKey() case .deviceLocked, .authenticationFailed: // Recoverable error - use ephemeral key and warn SecureLogger.warning("Device locked or auth failed - using ephemeral signing key until unlocked", category: .keychain) loadedSigningKey = Curve25519.Signing.PrivateKey() case .otherError(let status): // Unexpected error - log and use ephemeral key SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), context: "Unexpected keychain error - using ephemeral signing key", category: .keychain) loadedSigningKey = Curve25519.Signing.PrivateKey() } // Now assign the signing keys self.signingKey = loadedSigningKey self.signingPublicKey = signingKey.publicKey // Initialize session manager self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) // Set up session callbacks sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey) } // Start session maintenance timer startRekeyTimer() } // MARK: - BCH-01-009: Key Generation Helpers with Save Verification /// Generate and save a new Noise static key, verifying the save succeeds private static func generateAndSaveNoiseKey(keychain: KeychainManagerProtocol) -> Curve25519.KeyAgreement.PrivateKey { let newKey = Curve25519.KeyAgreement.PrivateKey() let keyData = newKey.rawRepresentation // Save to keychain and verify success let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "noiseStaticKey") switch saveResult { case .success: SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: true) case .duplicateItem: // This shouldn't happen since we just tried to load, but handle it SecureLogger.warning("Noise key already exists (race condition?)", category: .keychain) default: // Save failed - log but continue with the key (it will be ephemeral) SecureLogger.error(NSError(domain: "Keychain", code: -1), context: "Failed to persist noise static key - identity will be lost on restart", category: .keychain) } return newKey } /// Generate and save a new Ed25519 signing key, verifying the save succeeds private static func generateAndSaveSigningKey(keychain: KeychainManagerProtocol) -> Curve25519.Signing.PrivateKey { let newKey = Curve25519.Signing.PrivateKey() let keyData = newKey.rawRepresentation // Save to keychain and verify success let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "ed25519SigningKey") switch saveResult { case .success: SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: true) case .duplicateItem: // This shouldn't happen since we just tried to load, but handle it SecureLogger.warning("Signing key already exists (race condition?)", category: .keychain) default: // Save failed - log but continue with the key (it will be ephemeral) SecureLogger.error(NSError(domain: "Keychain", code: -1), context: "Failed to persist signing key - identity will be lost on restart", category: .keychain) } return newKey } // MARK: - Public Interface /// Get our static public key for sharing func getStaticPublicKeyData() -> Data { return staticIdentityPublicKey.rawRepresentation } /// Get our signing public key for sharing func getSigningPublicKeyData() -> Data { return signingPublicKey.rawRepresentation } /// Get our identity fingerprint func getIdentityFingerprint() -> String { staticIdentityPublicKey.rawRepresentation.sha256Fingerprint() } /// Get peer's public key data func getPeerPublicKeyData(_ peerID: PeerID) -> Data? { return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation } /// Clear persistent identity (for panic mode) func clearPersistentIdentity() { // Clear from keychain let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey") let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey") SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning) SecureLogger.warning("Panic mode activated - identity cleared", category: .security) // Stop rekey timer stopRekeyTimer() } /// Sign data with our Ed25519 signing key func signData(_ data: Data) -> Data? { do { let signature = try signingKey.signature(for: data) return signature } catch { SecureLogger.error(error, context: "Failed to sign data") return nil } } /// Verify signature with a peer's Ed25519 public key func verifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { do { let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey) return signingPublicKey.isValidSignature(signature, for: data) } catch { SecureLogger.error(error, context: "Failed to verify signature") return false } } // MARK: - Announce Signature Helpers /// Build the canonical announce binding message bytes and sign with our Ed25519 key /// - Parameters: /// - peerID: 8-byte routing ID (as in packet header) /// - noiseKey: 32-byte Curve25519.KeyAgreement public key /// - ed25519Key: 32-byte Ed25519 public key (self) /// - nickname: UTF-8 nickname (<=255 bytes) /// - timestampMs: UInt64 milliseconds since epoch /// - Returns: Ed25519 signature over the canonical bytes, or nil on failure func buildAnnounceSignature(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data? { let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs) return signData(message) } /// Verify an announce signature func verifyAnnounceSignature(signature: Data, peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64, publicKey: Data) -> Bool { let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs) return verifySignature(signature, for: message, publicKey: publicKey) } /// Build canonical bytes for announce signing. private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data { var out = Data() // context let context = "bitchat-announce-v1".data(using: .utf8) ?? Data() out.append(UInt8(min(context.count, 255))) out.append(context.prefix(255)) // peerID (expect 8 bytes; pad/truncate to 8 for canonicalization) let peerID8 = peerID.prefix(8) out.append(peerID8) if peerID8.count < 8 { out.append(Data(repeating: 0, count: 8 - peerID8.count)) } // noise static key (expect 32) let noise32 = noiseKey.prefix(32) out.append(noise32) if noise32.count < 32 { out.append(Data(repeating: 0, count: 32 - noise32.count)) } // ed25519 public key (expect 32) let ed32 = ed25519Key.prefix(32) out.append(ed32) if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) } // nickname length + bytes let nickData = nickname.data(using: .utf8) ?? Data() out.append(UInt8(min(nickData.count, 255))) out.append(nickData.prefix(255)) // timestamp var ts = timestampMs.bigEndian withUnsafeBytes(of: &ts) { raw in out.append(contentsOf: raw) } return out } // MARK: - Packet Signing/Verification /// Sign a BitchatPacket using the noise private key func signPacket(_ packet: BitchatPacket) -> BitchatPacket? { // Create canonical packet bytes for signing guard let packetData = packet.toBinaryDataForSigning() else { return nil } // Sign with the noise private key (converted to Ed25519 for signing) guard let signature = signData(packetData) else { return nil } // Return new packet with signature var signedPacket = packet signedPacket.signature = signature return signedPacket } /// Verify a BitchatPacket signature using the provided public key func verifyPacketSignature(_ packet: BitchatPacket, publicKey: Data) -> Bool { guard let signature = packet.signature else { return false } // Create canonical packet bytes for verification (without signature) guard let packetData = packet.toBinaryDataForSigning() else { return false } // For noise public keys, we need to derive the Ed25519 key for verification // This assumes the noise key can be used for Ed25519 signing return verifySignature(signature, for: packetData, publicKey: publicKey) } // MARK: - Handshake Management /// Initiate a Noise handshake with a peer func initiateHandshake(with peerID: PeerID) throws -> Data { // Validate peer ID guard peerID.isValid else { SecureLogger.warning(.authenticationFailed(peerID: peerID.id)) throw NoiseSecurityError.invalidPeerID } // Check rate limit guard rateLimiter.allowHandshake(from: peerID) else { SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)")) throw NoiseSecurityError.rateLimitExceeded } SecureLogger.info(.handshakeStarted(peerID: peerID.id)) // Return raw handshake data without wrapper // The Noise protocol handles its own message format let handshakeData = try sessionManager.initiateHandshake(with: peerID) return handshakeData } /// Process an incoming handshake message func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? { // Validate peer ID guard peerID.isValid else { SecureLogger.warning(.authenticationFailed(peerID: peerID.id)) throw NoiseSecurityError.invalidPeerID } // Validate message size guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else { SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large")) throw NoiseSecurityError.messageTooLarge } // Check rate limit guard rateLimiter.allowHandshake(from: peerID) else { SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)")) throw NoiseSecurityError.rateLimitExceeded } // For handshakes, we process the raw data directly without NoiseMessage wrapper // The Noise protocol handles its own message format let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message) // Return raw response without wrapper return responsePayload } /// Check if we have an established session with a peer func hasEstablishedSession(with peerID: PeerID) -> Bool { return sessionManager.getSession(for: peerID)?.isEstablished() ?? false } /// Check if we have a session (established or handshaking) with a peer func hasSession(with peerID: PeerID) -> Bool { return sessionManager.getSession(for: peerID) != nil } // MARK: - Encryption/Decryption /// Encrypt data for a specific peer func encrypt(_ data: Data, for peerID: PeerID) throws -> Data { // Validate message size guard NoiseSecurityValidator.validateMessageSize(data) else { throw NoiseSecurityError.messageTooLarge } // Check rate limit guard rateLimiter.allowMessage(from: peerID) else { throw NoiseSecurityError.rateLimitExceeded } // Check if we have an established session guard hasEstablishedSession(with: peerID) else { // Signal that handshake is needed onHandshakeRequired?(peerID) throw NoiseEncryptionError.handshakeRequired } return try sessionManager.encrypt(data, for: peerID) } /// Decrypt data from a specific peer func decrypt(_ data: Data, from peerID: PeerID) throws -> Data { // Validate message size guard NoiseSecurityValidator.validateMessageSize(data) else { throw NoiseSecurityError.messageTooLarge } // Check rate limit guard rateLimiter.allowMessage(from: peerID) else { throw NoiseSecurityError.rateLimitExceeded } // Check if we have an established session guard hasEstablishedSession(with: peerID) else { throw NoiseEncryptionError.sessionNotEstablished } return try sessionManager.decrypt(data, from: peerID) } // MARK: - Peer Management /// Get fingerprint for a peer func getPeerFingerprint(_ peerID: PeerID) -> String? { return serviceQueue.sync { return peerFingerprints[peerID] } } func clearEphemeralStateForPanic() { sessionManager.removeAllSessions() serviceQueue.sync(flags: .barrier) { peerFingerprints.removeAll() fingerprintToPeerID.removeAll() } rateLimiter.resetAll() } /// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake) func clearSession(for peerID: PeerID) { sessionManager.removeSession(for: peerID) serviceQueue.sync(flags: .barrier) { if let fingerprint = peerFingerprints.removeValue(forKey: peerID) { fingerprintToPeerID.removeValue(forKey: fingerprint) } } SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session) } // MARK: - Private Helpers private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { // Calculate fingerprint let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint() // Store fingerprint mapping serviceQueue.sync(flags: .barrier) { peerFingerprints[peerID] = fingerprint fingerprintToPeerID[fingerprint] = peerID } // Log security event SecureLogger.info(.handshakeCompleted(peerID: peerID.id)) // Notify all handlers about authentication serviceQueue.async { [weak self] in self?.onPeerAuthenticatedHandlers.forEach { handler in handler(peerID, fingerprint) } } } // MARK: - Session Maintenance private func startRekeyTimer() { rekeyTimer = Timer.scheduledTimer(withTimeInterval: rekeyCheckInterval, repeats: true) { [weak self] _ in self?.checkSessionsForRekey() } } private func stopRekeyTimer() { rekeyTimer?.invalidate() rekeyTimer = nil } private func checkSessionsForRekey() { let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey() for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey { // Attempt to rekey the session do { try sessionManager.initiateRekey(for: peerID) SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security) // Signal that handshake is needed onHandshakeRequired?(peerID) } catch { SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session) } } } deinit { stopRekeyTimer() } } // MARK: - Protocol Message Types for Noise /// Message types for the Noise encryption protocol layer. /// These types wrap the underlying BitChat protocol messages with encryption metadata. enum NoiseMessageType: UInt8 { case handshakeInitiation = 0x10 case handshakeResponse = 0x11 case handshakeFinal = 0x12 case encryptedMessage = 0x13 case sessionRenegotiation = 0x14 } // MARK: - Noise Message Wrapper /// Container for encrypted messages in the Noise protocol. /// Provides versioning and type information for proper message handling. /// The actual message content is encrypted in the payload field. struct NoiseMessage: Codable { let type: UInt8 let sessionID: String // Random ID for this handshake session let payload: Data init(type: NoiseMessageType, sessionID: String, payload: Data) { self.type = type.rawValue self.sessionID = sessionID self.payload = payload } func encode() -> Data? { do { let encoded = try JSONEncoder().encode(self) return encoded } catch { return nil } } static func decode(from data: Data) -> NoiseMessage? { return try? JSONDecoder().decode(NoiseMessage.self, from: data) } static func decodeWithError(from data: Data) -> NoiseMessage? { do { let decoded = try JSONDecoder().decode(NoiseMessage.self, from: data) return decoded } catch { return nil } } // MARK: - Binary Encoding func toBinaryData() -> Data { var data = Data() data.appendUInt8(type) data.appendUUID(sessionID) data.appendData(payload) return data } static func fromBinaryData(_ data: Data) -> NoiseMessage? { // Create defensive copy let dataCopy = Data(data) var offset = 0 guard let type = dataCopy.readUInt8(at: &offset), let sessionID = dataCopy.readUUID(at: &offset), let payload = dataCopy.readData(at: &offset) else { return nil } guard let messageType = NoiseMessageType(rawValue: type) else { return nil } return NoiseMessage(type: messageType, sessionID: sessionID, payload: payload) } } // MARK: - Errors enum NoiseEncryptionError: Error { case handshakeRequired case sessionNotEstablished } ================================================ FILE: bitchat/Services/NostrTransport.swift ================================================ import BitLogger import Foundation import Combine // Minimal Nostr transport conforming to Transport for offline sending final class NostrTransport: Transport, @unchecked Sendable { struct Dependencies { let notificationCenter: NotificationCenter let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship] let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship? let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship? let currentIdentity: @MainActor () throws -> NostrIdentity? let registerPendingGiftWrap: @MainActor (String) -> Void let sendEvent: @MainActor (NostrEvent) -> Void let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void static func live(idBridge: NostrIdentityBridge) -> Dependencies { Dependencies( notificationCenter: .default, loadFavorites: { FavoritesPersistenceService.shared.favorites }, favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) }, favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) }, currentIdentity: { try idBridge.getCurrentNostrIdentity() }, registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) }, sendEvent: { NostrRelayManager.shared.sendEvent($0) }, scheduleAfter: { delay, action in DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) } ) } } // Provide BLE short peer ID for BitChat embedding var senderPeerID = PeerID(str: "") // Throttle READ receipts to avoid relay rate limits private struct QueuedRead { let receipt: ReadReceipt let peerID: PeerID } private var readQueue: [QueuedRead] = [] private var isSendingReadAcks = false private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval private let keychain: KeychainManagerProtocol private let idBridge: NostrIdentityBridge private let dependencies: Dependencies private var favoriteStatusObserver: NSObjectProtocol? // Reachability Cache (thread-safe) private var reachablePeers: Set = [] private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent) @MainActor init( keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, dependencies: Dependencies? = nil ) { self.keychain = keychain self.idBridge = idBridge self.dependencies = dependencies ?? .live(idBridge: idBridge) setupObservers() // Synchronously warm the cache to avoid startup race let favorites = self.dependencies.loadFavorites() let reachable = favorites.values .filter { $0.peerNostrPublicKey != nil } .map { PeerID(publicKey: $0.peerNoisePublicKey) } queue.sync(flags: .barrier) { self.reachablePeers = Set(reachable) } } deinit { if let favoriteStatusObserver { dependencies.notificationCenter.removeObserver(favoriteStatusObserver) } } private func setupObservers() { favoriteStatusObserver = dependencies.notificationCenter.addObserver( forName: .favoriteStatusChanged, object: nil, queue: nil ) { [weak self] _ in self?.refreshReachablePeers() } } private func refreshReachablePeers() { Task { @MainActor in let favorites = dependencies.loadFavorites() let reachable = favorites.values .filter { $0.peerNostrPublicKey != nil } .map { PeerID(publicKey: $0.peerNoisePublicKey) } self.queue.async(flags: .barrier) { [weak self] in self?.reachablePeers = Set(reachable) } } } // MARK: - Transport Protocol Conformance weak var delegate: BitchatDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { Just([]).eraseToAnyPublisher() } func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] } var myPeerID: PeerID { senderPeerID } var myNickname: String { "" } func setNickname(_ nickname: String) { /* not used for Nostr */ } func startServices() { /* no-op */ } func stopServices() { /* no-op */ } func emergencyDisconnectAll() { /* no-op */ } func isPeerConnected(_ peerID: PeerID) -> Bool { false } func isPeerReachable(_ peerID: PeerID) -> Bool { queue.sync { // Check if exact match if reachablePeers.contains(peerID) { return true } // Check for short ID match if peerID.isShort { return reachablePeers.contains(where: { $0.toShort() == peerID }) } return false } } func peerNickname(peerID: PeerID) -> String? { nil } func getPeerNicknames() -> [PeerID : String] { [:] } func getFingerprint(for peerID: PeerID) -> String? { nil } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func triggerHandshake(with peerID: PeerID) { /* no-op */ } // Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation private static var cachedNoiseService: NoiseEncryptionService? func getNoiseService() -> NoiseEncryptionService { if let noiseService = Self.cachedNoiseService { return noiseService } let noiseService = NoiseEncryptionService(keychain: keychain) Self.cachedNoiseService = noiseService return noiseService } // Public broadcast not supported over Nostr here func sendMessage(_ content: String, mentions: [String]) { /* no-op */ } func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { Task { @MainActor in guard let recipientNpub = resolveRecipientNpub(for: peerID), let recipientHex = npubToHex(recipientNpub), let senderIdentity = try? dependencies.currentIdentity() else { return } SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session) return } sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity) } } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { // Enqueue and process with throttling to avoid relay rate limits // Use barrier to synchronize access to readQueue queue.async(flags: .barrier) { [weak self] in self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID)) self?.processReadQueueIfNeeded() } } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { Task { @MainActor in guard let recipientNpub = resolveRecipientNpub(for: peerID), let recipientHex = npubToHex(recipientNpub), let senderIdentity = try? dependencies.currentIdentity() else { return } let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session) return } sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity) } } func sendBroadcastAnnounce() { /* no-op for Nostr */ } func sendDeliveryAck(for messageID: String, to peerID: PeerID) { Task { @MainActor in guard let recipientNpub = resolveRecipientNpub(for: peerID), let recipientHex = npubToHex(recipientNpub), let senderIdentity = try? dependencies.currentIdentity() else { return } SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session) guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session) return } sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) } } } // MARK: - Geohash Helpers extension NostrTransport { // MARK: Geohash ACK helpers func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { Task { @MainActor in SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return } sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) } } func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { Task { @MainActor in SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return } sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) } } // MARK: Geohash DMs (per-geohash identity) func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { Task { @MainActor in guard !recipientHex.isEmpty else { return } SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session) return } sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) } } } // MARK: - Private Helpers extension NostrTransport { /// Converts npub bech32 string to hex pubkey @MainActor private func npubToHex(_ npub: String) -> String? { do { let (hrp, data) = try Bech32.decode(npub) guard hrp == "npub" else { return nil } return data.hexEncodedString() } catch { SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session) return nil } } /// Creates and sends a gift-wrapped private message event @MainActor private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) { guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session) return } if registerPending { dependencies.registerPendingGiftWrap(event.id) } dependencies.sendEvent(event) } /// Must be called within a barrier on `queue` private func processReadQueueIfNeeded() { guard !isSendingReadAcks else { return } guard !readQueue.isEmpty else { return } isSendingReadAcks = true let item = readQueue.removeFirst() sendReadAckItem(item) } /// Sends a single read ack item (called after extraction from queue within barrier) private func sendReadAckItem(_ item: QueuedRead) { Task { @MainActor in defer { scheduleNextReadAck() } guard let recipientNpub = resolveRecipientNpub(for: item.peerID), let recipientHex = npubToHex(recipientNpub), let senderIdentity = try? dependencies.currentIdentity() else { return } SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session) guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) return } sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) } } private func scheduleNextReadAck() { dependencies.scheduleAfter(readAckInterval) { [weak self] in self?.queue.async(flags: .barrier) { [weak self] in self?.isSendingReadAcks = false self?.processReadQueueIfNeeded() } } } @MainActor private func resolveRecipientNpub(for peerID: PeerID) -> String? { if let noiseKey = Data(hexString: peerID.id), let fav = dependencies.favoriteStatusForNoiseKey(noiseKey), let npub = fav.peerNostrPublicKey { return npub } if peerID.id.count == 16, let fav = dependencies.favoriteStatusForPeerID(peerID), let npub = fav.peerNostrPublicKey { return npub } return nil } } ================================================ FILE: bitchat/Services/NotificationService.swift ================================================ // // NotificationService.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation import UserNotifications #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif protocol NotificationAuthorizing { func requestAuthorization( options: UNAuthorizationOptions, completionHandler: @escaping (Bool, Error?) -> Void ) } protocol NotificationRequestDelivering { func add(_ request: UNNotificationRequest) } private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing { private let center: UNUserNotificationCenter init(center: UNUserNotificationCenter) { self.center = center } func requestAuthorization( options: UNAuthorizationOptions, completionHandler: @escaping (Bool, Error?) -> Void ) { center.requestAuthorization(options: options, completionHandler: completionHandler) } } private final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering { private let center: UNUserNotificationCenter init(center: UNUserNotificationCenter) { self.center = center } func add(_ request: UNNotificationRequest) { Task { try? await center.add(request) } } } private struct NoopNotificationAuthorizer: NotificationAuthorizing { func requestAuthorization( options: UNAuthorizationOptions, completionHandler: @escaping (Bool, Error?) -> Void ) { completionHandler(false, nil) } } private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering { func add(_ request: UNNotificationRequest) {} } final class NotificationService { static let shared = NotificationService() private let isRunningTestsProvider: () -> Bool private let authorizer: NotificationAuthorizing private let requestDeliverer: NotificationRequestDelivering /// Returns true if running in test environment (XCTest, Swift Testing, or CI) private var isRunningTests: Bool { isRunningTestsProvider() } private init() { self.isRunningTestsProvider = { let env = ProcessInfo.processInfo.environment return NSClassFromString("XCTestCase") != nil || env["XCTestConfigurationFilePath"] != nil || env["XCTestBundlePath"] != nil || env["GITHUB_ACTIONS"] != nil || env["CI"] != nil } if isRunningTestsProvider() { self.authorizer = NoopNotificationAuthorizer() self.requestDeliverer = NoopNotificationRequestDeliverer() } else { let center = UNUserNotificationCenter.current() self.authorizer = NotificationCenterAuthorizerAdapter(center: center) self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center) } } internal init( isRunningTestsProvider: @escaping () -> Bool, authorizer: NotificationAuthorizing, requestDeliverer: NotificationRequestDelivering ) { self.isRunningTestsProvider = isRunningTestsProvider self.authorizer = authorizer self.requestDeliverer = requestDeliverer } func requestAuthorization() { guard !isRunningTests else { return } authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in if granted { // Permission granted } else { // Permission denied } } } func sendLocalNotification( title: String, body: String, identifier: String, userInfo: [String: Any]? = nil, interruptionLevel: UNNotificationInterruptionLevel = .active ) { guard !isRunningTests else { return } let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default content.interruptionLevel = interruptionLevel if let userInfo = userInfo { content.userInfo = userInfo } let request = UNNotificationRequest( identifier: identifier, content: content, trigger: nil // Deliver immediately ) requestDeliverer.add(request) } func sendMentionNotification(from sender: String, message: String) { let title = "🫵 you were mentioned by \(sender)" let body = message let identifier = "mention-\(UUID().uuidString)" sendLocalNotification(title: title, body: body, identifier: identifier) } func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) { let title = "🔒 DM from \(sender)" let body = message let identifier = "private-\(UUID().uuidString)" let userInfo = ["peerID": peerID.id, "senderName": sender] sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo) } // Geohash public chat notification with deep link to a specific geohash func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) { let title = "\(titlePrefix)\(geohash)" let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)" let deeplink = "bitchat://geohash/\(geohash)" let userInfo: [String: Any] = ["deeplink": deeplink] sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo) } func sendNetworkAvailableNotification(peerCount: Int) { let title = "👥 bitchatters nearby!" let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around" // Fixed identifier so iOS updates the existing notification instead of creating new ones let identifier = "network-available" sendLocalNotification( title: title, body: body, identifier: identifier, interruptionLevel: .timeSensitive ) } } ================================================ FILE: bitchat/Services/NotificationStreamAssembler.swift ================================================ // // NotificationStreamAssembler.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import BitLogger import Foundation struct NotificationStreamAssembler { private var buffer = Data() private var pendingFrameStartedAt: DispatchTime? private var pendingFrameExpectedLength: Int = 0 private mutating func resetState() { buffer.removeAll(keepingCapacity: false) pendingFrameStartedAt = nil pendingFrameExpectedLength = 0 } mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) { guard !chunk.isEmpty else { return ([], [], false) } buffer.append(chunk) var frames: [Data] = [] var dropped: [UInt8] = [] var didReset = false let now = DispatchTime.now() let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes { SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session) resetState() return ([], [], true) } while buffer.count >= minimumFramePrefix { guard let version = buffer.first else { break } guard version == 1 || version == 2 else { dropped.append(buffer.removeFirst()) pendingFrameStartedAt = nil pendingFrameExpectedLength = 0 continue } guard let headerSize = BinaryProtocol.headerSize(for: version) else { dropped.append(buffer.removeFirst()) pendingFrameStartedAt = nil pendingFrameExpectedLength = 0 continue } let framePrefix = headerSize + BinaryProtocol.senderIDSize guard buffer.count >= framePrefix else { break } let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags guard flagsIndex < buffer.endIndex else { break } let flags = buffer[flagsIndex] let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0 let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0 let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0 let hasRoute = (version >= 2) && (flags & BinaryProtocol.Flags.hasRoute) != 0 let lengthOffset = 12 let payloadLength: Int if version == 2 { let lengthIndex = buffer.startIndex + lengthOffset payloadLength = (Int(buffer[lengthIndex]) << 24) | (Int(buffer[lengthIndex + 1]) << 16) | (Int(buffer[lengthIndex + 2]) << 8) | Int(buffer[lengthIndex + 3]) } else { let lengthIndex = buffer.startIndex + lengthOffset payloadLength = (Int(buffer[lengthIndex]) << 8) | Int(buffer[lengthIndex + 1]) } var frameLength = framePrefix + payloadLength if hasRecipient { frameLength += BinaryProtocol.recipientIDSize } if hasSignature { frameLength += BinaryProtocol.signatureSize } if hasRoute { let routeCountOffset = framePrefix + (hasRecipient ? BinaryProtocol.recipientIDSize : 0) let routeCountIndex = buffer.startIndex + routeCountOffset guard buffer.count > routeCountOffset else { break } let routeCount = Int(buffer[routeCountIndex]) frameLength += 1 + (routeCount * BinaryProtocol.senderIDSize) } if isCompressed { let rawLengthFieldBytes = (version == 2) ? 4 : 2 if payloadLength < rawLengthFieldBytes { SecureLogger.error("❌ Invalid compressed payload length (\(payloadLength))", category: .session) resetState() didReset = true break } } guard frameLength > 0, frameLength <= maxFrameLength else { SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session) resetState() didReset = true break } if buffer.count < frameLength { let remaining = frameLength - buffer.count if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength { pendingFrameStartedAt = now pendingFrameExpectedLength = frameLength } else if let started = pendingFrameStartedAt { let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds let threshold = UInt64(TransportConfig.bleAssemblerStallResetMs) * 1_000_000 if elapsed >= threshold { SecureLogger.debug("📉 Resetting notification assembler after waiting \(remaining)B for \(TransportConfig.bleAssemblerStallResetMs)ms", category: .session) resetState() didReset = true } else { SecureLogger.debug("⌛ Waiting for remaining \(remaining)B to complete BLE frame", category: .session) } } break } pendingFrameStartedAt = nil pendingFrameExpectedLength = 0 let frame = Data(buffer.prefix(frameLength)) frames.append(frame) buffer.removeFirst(frameLength) } if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) { resetState() } return (frames, dropped, didReset) } } ================================================ FILE: bitchat/Services/PrivateChatManager.swift ================================================ // // PrivateChatManager.swift // bitchat // // Manages private chat sessions and messages // This is free and unencumbered software released into the public domain. // import BitLogger import Foundation import SwiftUI /// Manages all private chat functionality final class PrivateChatManager: ObservableObject { @Published var privateChats: [PeerID: [BitchatMessage]] = [:] @Published var selectedPeer: PeerID? = nil @Published var unreadMessages: Set = [] private var selectedPeerFingerprint: String? = nil var sentReadReceipts: Set = [] // Made accessible for ChatViewModel weak var meshService: Transport? // Route acks/receipts via MessageRouter (chooses mesh or Nostr) weak var messageRouter: MessageRouter? // Peer service for looking up peer info during consolidation weak var unifiedPeerService: UnifiedPeerService? init(meshService: Transport? = nil) { self.meshService = meshService } // Cap for messages stored per private chat private let privateChatCap = TransportConfig.privateChatCap // MARK: - Message Consolidation /// Consolidates messages from different peer ID representations into a single chat. /// This ensures messages from stable Noise keys and temporary Nostr peer IDs are merged. /// - Parameters: /// - peerID: The target peer ID to consolidate messages into /// - peerNickname: The peer's display name (lowercased for matching) /// - persistedReadReceipts: The persisted read receipts set from ChatViewModel (UserDefaults-backed) /// - Returns: True if any unread messages were found during consolidation @MainActor func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set) -> Bool { guard let meshService = meshService else { return false } var hasUnreadMessages = false // 1. Consolidate from stable Noise key (64-char hex) if let peer = unifiedPeerService?.getPeer(by: peerID) { let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty { if privateChats[peerID] == nil { privateChats[peerID] = [] } let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? []) for message in nostrMessages { if !existingMessageIds.contains(message.id) { // Update senderPeerID for correct read receipts 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, mentions: message.mentions, deliveryStatus: message.deliveryStatus ) privateChats[peerID]?.append(updatedMessage) // Check for recent unread messages (< 60s, not sent by us, not already read) // Use persistedReadReceipts to correctly identify already-read messages after app restart if message.senderPeerID != meshService.myPeerID { let messageAge = Date().timeIntervalSince(message.timestamp) if messageAge < 60 && !persistedReadReceipts.contains(message.id) { hasUnreadMessages = true } } } } privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } if hasUnreadMessages { unreadMessages.insert(peerID) } else if unreadMessages.contains(noiseKeyHex) { unreadMessages.remove(noiseKeyHex) } privateChats.removeValue(forKey: noiseKeyHex) } } // 2. Consolidate from temporary Nostr peer IDs (nostr_* prefixed) let normalizedNickname = peerNickname.lowercased() var tempPeerIDsToConsolidate: [PeerID] = [] for (storedPeerID, messages) in privateChats { if storedPeerID.isGeoDM && storedPeerID != peerID { let nicknamesMatch = messages.allSatisfy { $0.sender.lowercased() == normalizedNickname } if nicknamesMatch && !messages.isEmpty { tempPeerIDsToConsolidate.append(storedPeerID) } } } 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 { if unreadMessages.contains(tempPeerID) { hadUnreadTemp = true } if let tempMessages = privateChats[tempPeerID] { for message in tempMessages { if !existingMessageIds.contains(message.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, mentions: message.mentions, deliveryStatus: message.deliveryStatus ) privateChats[peerID]?.append(updatedMessage) consolidatedCount += 1 } } privateChats.removeValue(forKey: tempPeerID) unreadMessages.remove(tempPeerID) } } if hadUnreadTemp { unreadMessages.insert(peerID) hasUnreadMessages = true SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session) } if consolidatedCount > 0 { privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session) } } return hasUnreadMessages } /// Syncs the read receipt tracking between manager and view model for sent messages @MainActor func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set) { guard let messages = privateChats[peerID] else { return } for message in messages { if message.sender == nickname { if let status = message.deliveryStatus { switch status { case .read, .delivered: externalReceipts.insert(message.id) sentReadReceipts.insert(message.id) case .failed, .partiallyDelivered, .sending, .sent: break } } } } } /// Start a private chat with a peer func startChat(with peerID: PeerID) { selectedPeer = peerID // Store fingerprint for persistence across reconnections if let fingerprint = meshService?.getFingerprint(for: 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 } /// Remove duplicate messages by ID and keep chronological order func sanitizeChat(for peerID: PeerID) { guard let arr = privateChats[peerID] else { return } if arr.count <= 1 { return } var indexByID: [String: Int] = [:] indexByID.reserveCapacity(arr.count) var deduped: [BitchatMessage] = [] deduped.reserveCapacity(arr.count) for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) { if let existing = indexByID[msg.id] { deduped[existing] = msg } else { indexByID[msg.id] = deduped.count deduped.append(msg) } } privateChats[peerID] = deduped } /// Mark messages from a peer as read func markAsRead(from peerID: PeerID) { 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) } } } } // 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 ?? PeerID(str: ""), readerNickname: meshService?.myNickname ?? "" ) // Route via MessageRouter to avoid handshakeRequired spam when session isn't established if let router = messageRouter { SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session) Task { @MainActor in router.sendReadReceipt(receipt, to: senderPeerID) } } else { // Fallback: preserve previous behavior meshService?.sendReadReceipt(receipt, to: senderPeerID) } } } ================================================ FILE: bitchat/Services/RelayController.swift ================================================ import Foundation // RelayDecision encapsulates a single relay scheduling choice. struct RelayDecision { let shouldRelay: Bool let newTTL: UInt8 let delayMs: Int } // RelayController centralizes flood control policy for relays. struct RelayController { static func decide(ttl: UInt8, senderIsSelf: Bool, isEncrypted: Bool, isDirectedEncrypted: Bool, isFragment: Bool, isDirectedFragment: Bool, isHandshake: Bool, isAnnounce: Bool, degree: Int, highDegreeThreshold: Int) -> RelayDecision { let ttlCap = min(ttl, TransportConfig.messageTTLDefault) // Suppress obvious non-relays if ttlCap <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0) } // For session-critical or directed traffic, be deterministic and reliable if isHandshake || isDirectedFragment || isDirectedEncrypted { // Always relay with no TTL cap for these types let newTTL = ttlCap &- 1 // Slight jitter to desynchronize without adding too much latency // Tighter for faster multi-hop handshakes and directed DMs let delayRange: ClosedRange = isHandshake ? 10...35 : 20...60 let delayMs = Int.random(in: delayRange) return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) } if isFragment { let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap) guard ttlLimit > 1 else { return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0) } let newTTL = ttlLimit &- 1 let delayMs = Int.random(in: TransportConfig.bleFragmentRelayMinDelayMs...TransportConfig.bleFragmentRelayMaxDelayMs) return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) } // TTL clamping for broadcast // - Dense graphs: keep lower but still allow multi-hop bridging // - Announces get a bit more headroom let ttlLimit: UInt8 = { if degree >= highDegreeThreshold { return max(UInt8(2), min(ttlCap, UInt8(5))) } let preferred = UInt8(isAnnounce ? 7 : 6) return max(UInt8(2), min(ttlCap, preferred)) }() let newTTL = ttlLimit &- 1 // Wider jitter window to allow duplicate suppression to win more often // For sparse graphs (<=2), relay quickly to avoid cancellation races let delayMs: Int switch degree { case 0...2: delayMs = Int.random(in: 10...40) case 3...5: delayMs = Int.random(in: 60...150) case 6...9: delayMs = Int.random(in: 80...180) default: delayMs = Int.random(in: 100...220) } return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) } } ================================================ FILE: bitchat/Services/TransferProgressManager.swift ================================================ import Foundation import Combine /// Centralized progress bus for Bluetooth file transfers. /// Emits Combine events consumed by ChatViewModel to update UI progress indicators. final class TransferProgressManager { static let shared = TransferProgressManager() enum Event { case started(id: String, totalFragments: Int) case updated(id: String, sentFragments: Int, totalFragments: Int) case completed(id: String, totalFragments: Int) case cancelled(id: String, sentFragments: Int, totalFragments: Int) } private let subject = PassthroughSubject() private let queue = DispatchQueue(label: "com.bitchat.transfer-progress", attributes: .concurrent) private var states: [String: (sent: Int, total: Int)] = [:] var publisher: AnyPublisher { subject.eraseToAnyPublisher() } func start(id: String, totalFragments: Int) { queue.async(flags: .barrier) { [weak self] in guard let self = self else { return } self.states[id] = (sent: 0, total: totalFragments) self.subject.send(.started(id: id, totalFragments: totalFragments)) } } func recordFragmentSent(id: String) { queue.async(flags: .barrier) { [weak self] in guard let self = self, var state = self.states[id] else { return } state.sent = min(state.sent + 1, state.total) self.states[id] = state self.subject.send(.updated(id: id, sentFragments: state.sent, totalFragments: state.total)) if state.sent >= state.total { self.states.removeValue(forKey: id) self.subject.send(.completed(id: id, totalFragments: state.total)) } } } func cancel(id: String) { queue.async(flags: .barrier) { [weak self] in guard let self = self, let state = self.states.removeValue(forKey: id) else { return } self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total)) } } func reset(id: String) { queue.async(flags: .barrier) { [weak self] in self?.states.removeValue(forKey: id) } } func snapshot(id: String) -> (sent: Int, total: Int)? { var result: (sent: Int, total: Int)? queue.sync { result = states[id] } return result } } ================================================ FILE: bitchat/Services/Transport.swift ================================================ import Foundation import Combine /// Abstract transport interface used by ChatViewModel and services. /// BLEService implements this protocol; a future Nostr transport can too. struct TransportPeerSnapshot: Equatable, Hashable { let peerID: PeerID let nickname: String let isConnected: Bool let noisePublicKey: Data? let lastSeen: Date } protocol Transport: AnyObject { // Event sink var delegate: BitchatDelegate? { get set } // Peer events (preferred over publishers for UI) var peerEventsDelegate: TransportPeerEventsDelegate? { get set } // Peer snapshots (for non-UI services) var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get } func currentPeerSnapshots() -> [TransportPeerSnapshot] // Identity var myPeerID: PeerID { get } var myNickname: String { get } func setNickname(_ nickname: String) // Lifecycle func startServices() func stopServices() func emergencyDisconnectAll() // Connectivity and peers func isPeerConnected(_ peerID: PeerID) -> Bool func isPeerReachable(_ peerID: PeerID) -> Bool func peerNickname(peerID: PeerID) -> String? func getPeerNicknames() -> [PeerID: String] // Protocol utilities func getFingerprint(for peerID: PeerID) -> String? func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState func triggerHandshake(with peerID: PeerID) func getNoiseService() -> NoiseEncryptionService // Messaging func sendMessage(_ content: String, mentions: [String]) func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) func sendBroadcastAnnounce() func sendDeliveryAck(for messageID: String, to peerID: PeerID) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) func cancelTransfer(_ transferId: String) // QR verification (optional for transports) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) // Pending file management (BCH-01-002: files held in memory until user accepts) func acceptPendingFile(id: String) -> URL? func declinePendingFile(id: String) } extension Transport { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func cancelTransfer(_ transferId: String) {} func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { sendMessage(content, mentions: mentions) } func acceptPendingFile(id: String) -> URL? { nil } func declinePendingFile(id: String) {} } protocol TransportPeerEventsDelegate: AnyObject { @MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) } extension BLEService: Transport {} ================================================ FILE: bitchat/Services/TransportConfig.swift ================================================ import Foundation /// Centralized knobs for transport- and UI-related limits. /// Keep values aligned with existing behavior when replacing magic numbers. enum TransportConfig { // BLE / Protocol static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods // UI / Storage Caps static let privateChatCap: Int = 1337 static let meshTimelineCap: Int = 1337 static let geoTimelineCap: Int = 1337 static let contentLRUCap: Int = 2000 // Timers static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes static let networkNotificationCooldownSeconds: TimeInterval = 300 // 5 minutes static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching // BLE duty/announce/connect static let bleConnectRateLimitInterval: TimeInterval = 0.5 static let bleMaxCentralLinks: Int = 6 static let bleDutyOnDuration: TimeInterval = 5.0 static let bleDutyOffDuration: TimeInterval = 10.0 static let bleAnnounceMinInterval: TimeInterval = 1.0 // BLE discovery/quality thresholds static let bleDynamicRSSIThresholdDefault: Int = -90 static let bleConnectionCandidatesMax: Int = 100 static let blePendingWriteBufferCapBytes: Int = 1_000_000 static let bleNotificationAssemblerHardCapBytes: Int = 8 * 1024 * 1024 static let bleAssemblerStallResetMs: Int = 250 static let blePendingNotificationsCapCount: Int = 128 static let bleNotificationRetryDelayMs: Int = 25 static let bleNotificationRetryMaxAttempts: Int = 80 // Nostr static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second // UI thresholds static let uiLateInsertThreshold: TimeInterval = 15.0 // Geohash public chats are more sensitive to ordering; use a tighter threshold static let uiLateInsertThresholdGeo: TimeInterval = 0.0 static let uiProcessedNostrEventsCap: Int = 2000 static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60 // UI rate limiters (token buckets) static let uiSenderRateBucketCapacity: Double = 5 static let uiSenderRateBucketRefillPerSec: Double = 1.0 static let uiContentRateBucketCapacity: Double = 3 static let uiContentRateBucketRefillPerSec: Double = 0.5 // UI sleeps/delays static let uiStartupInitialDelaySeconds: TimeInterval = 1.0 static let uiStartupShortSleepNs: UInt64 = 200_000_000 static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0 static let uiAsyncShortSleepNs: UInt64 = 100_000_000 static let uiAsyncMediumSleepNs: UInt64 = 500_000_000 static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1 static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5 static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15 static let uiScrollThrottleSeconds: TimeInterval = 0.5 static let uiAnimationShortSeconds: TimeInterval = 0.15 static let uiAnimationMediumSeconds: TimeInterval = 0.2 static let uiAnimationSidebarSeconds: TimeInterval = 0.25 static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60 static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0 // BLE maintenance & thresholds static let bleMaintenanceInterval: TimeInterval = 5.0 static let bleMaintenanceLeewaySeconds: Int = 1 static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60 static let bleRecentTimeoutWindowSeconds: TimeInterval = 60 static let bleRecentTimeoutCountThreshold: Int = 3 static let bleRSSIIsolatedBase: Int = -90 static let bleRSSIIsolatedRelaxed: Int = -92 static let bleRSSIConnectedThreshold: Int = -85 static let bleRSSIHighTimeoutThreshold: Int = -80 // How long without seeing traffic before we sanity-check the direct link // Lowered to make connected→reachable icon changes react faster when walking out of range static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0 // How long to retain a peer as "reachable" (not directly connected) since lastSeen static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified static let bleFragmentLifetimeSeconds: TimeInterval = 30.0 static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0 static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0 static let bleRecentPacketWindowSeconds: TimeInterval = 30.0 static let bleRecentPacketWindowMaxCount: Int = 100 // Keep scanning fully ON when we saw traffic very recently static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0 static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05 static let bleExpectedWritePerFragmentMs: Int = 20 static let bleExpectedWriteMaxMs: Int = 5000 // Fragment pacing: Conservative spacing to prevent BLE buffer overflow // Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery static let bleFragmentSpacingMs: Int = 30 static let bleFragmentSpacingDirectedMs: Int = 25 static let bleAnnounceIntervalSeconds: TimeInterval = 4.0 static let bleDutyOnDurationDense: TimeInterval = 3.0 static let bleDutyOffDurationDense: TimeInterval = 15.0 static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 30.0 static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 15.0 static let bleConnectedAnnounceJitterDense: TimeInterval = 8.0 static let bleConnectedAnnounceJitterSparse: TimeInterval = 4.0 // Location static let locationDistanceFilterMeters: Double = 1000 // Live (channel sheet open) distance threshold for meaningful updates static let locationDistanceFilterLiveMeters: Double = 10.0 static let locationLiveRefreshInterval: TimeInterval = 5.0 // Notifications (geohash) static let uiGeoNotifyCooldownSeconds: TimeInterval = 60.0 static let uiGeoNotifySnippetMaxLen: Int = 80 // Nostr geohash static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600 static let nostrGeohashInitialLimit: Int = 200 static let nostrGeoRelayCount: Int = 5 static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300 static let nostrGeohashSampleLimit: Int = 100 static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400 // Nostr helpers static let nostrShortKeyDisplayLength: Int = 8 static let nostrConvKeyPrefixLength: Int = 16 // Compression static let compressionThresholdBytes: Int = 100 // Message deduplication static let messageDedupMaxAgeSeconds: TimeInterval = 300 static let messageDedupMaxCount: Int = 1000 // Verification QR static let verificationQRMaxAgeSeconds: TimeInterval = 5 * 60 // Nostr relay backoff static let nostrRelayInitialBackoffSeconds: TimeInterval = 1.0 static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0 static let nostrRelayBackoffMultiplier: Double = 2.0 static let nostrRelayMaxReconnectAttempts: Int = 10 static let nostrRelayDefaultFetchLimit: Int = 100 // Geo relay directory static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24 static let geoRelayRefreshCheckIntervalSeconds: TimeInterval = 60 * 60 static let geoRelayRetryInitialSeconds: TimeInterval = 60 static let geoRelayRetryMaxSeconds: TimeInterval = 60 * 60 // BLE operational delays static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6 static let bleConnectTimeoutSeconds: TimeInterval = 8.0 static let bleRestartScanDelaySeconds: TimeInterval = 0.1 static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.05 static let blePostAnnounceDelaySeconds: TimeInterval = 0.4 static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15 // BCH-01-004: Rate-limiting for subscription-triggered announces // Prevents rapid enumeration attacks by rate-limiting announce responses static let bleSubscriptionRateLimitMinSeconds: TimeInterval = 2.0 // Minimum interval between announces per central static let bleSubscriptionRateLimitBackoffFactor: Double = 2.0 // Exponential backoff multiplier static let bleSubscriptionRateLimitMaxBackoffSeconds: TimeInterval = 30.0 // Maximum backoff period static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown // Store-and-forward for directed packets at relays static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0 // Log/UI debounce windows // Shorter debounce so UI reacts faster while still suppressing duplicate callbacks static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9 static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0 // Weak-link cooldown after connection timeouts static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0 static let bleWeakLinkRSSICutoff: Int = -90 // Content hashing / formatting static let contentKeyPrefixLength: Int = 256 static let uiLongMessageLengthThreshold: Int = 2000 static let uiVeryLongTokenThreshold: Int = 512 static let uiLongMessageLineLimit: Int = 30 static let uiFingerprintSampleCount: Int = 3 // UI swipe/gesture thresholds static let uiBackSwipeTranslationLarge: CGFloat = 50 static let uiBackSwipeTranslationSmall: CGFloat = 30 static let uiBackSwipeVelocityThreshold: CGFloat = 300 // UI color tuning static let uiColorHueAvoidanceDelta: Double = 0.05 static let uiColorHueOffset: Double = 0.12 // Peer list palette static let uiPeerPaletteSlots: Int = 36 static let uiPeerPaletteRingBrightnessDeltaLight: Double = 0.07 static let uiPeerPaletteRingBrightnessDeltaDark: Double = -0.07 // UI windowing (infinite scroll) static let uiWindowInitialCountPublic: Int = 300 static let uiWindowInitialCountPrivate: Int = 300 static let uiWindowStepCount: Int = 200 // Share extension static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0 static let uiShareAcceptWindowSeconds: TimeInterval = 30.0 static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60 // Gossip Sync Configuration static let syncSeenCapacity: Int = 1000 static let syncGCSMaxBytes: Int = 400 static let syncGCSTargetFpr: Double = 0.01 static let syncMaxMessageAgeSeconds: TimeInterval = 900 static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0 static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0 static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0 static let syncFragmentCapacity: Int = 600 static let syncFileTransferCapacity: Int = 200 static let syncFragmentIntervalSeconds: TimeInterval = 30.0 static let syncFileTransferIntervalSeconds: TimeInterval = 60.0 static let syncMessageIntervalSeconds: TimeInterval = 15.0 } ================================================ FILE: bitchat/Services/UnifiedPeerService.swift ================================================ // // UnifiedPeerService.swift // bitchat // // Unified peer state management combining mesh connectivity and favorites // This is free and unencumbered software released into the public domain. // import BitLogger import Foundation import Combine import SwiftUI /// Single source of truth for peer state, combining mesh connectivity and favorites @MainActor final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { // MARK: - Published Properties @Published private(set) var peers: [BitchatPeer] = [] @Published private(set) var connectedPeerIDs: Set = [] @Published private(set) var favorites: [BitchatPeer] = [] @Published private(set) var mutualFavorites: [BitchatPeer] = [] // MARK: - Private Properties private var peerIndex: [PeerID: BitchatPeer] = [:] private var fingerprintCache: [PeerID: String] = [:] private let meshService: Transport private let idBridge: NostrIdentityBridge private let identityManager: SecureIdentityStateManagerProtocol weak var messageRouter: MessageRouter? private let favoritesService = FavoritesPersistenceService.shared private var cancellables = Set() // MARK: - Initialization init( meshService: Transport, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol ) { self.meshService = meshService self.idBridge = idBridge self.identityManager = identityManager // Subscribe to changes from both services setupSubscriptions() // Perform initial update Task { @MainActor in updatePeers() } } // MARK: - Setup private func setupSubscriptions() { // Subscribe to mesh peer updates via delegate (preferred over publishers) meshService.peerEventsDelegate = self // Also listen for favorite change notifications NotificationCenter.default.publisher(for: .favoriteStatusChanged) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updatePeers() } .store(in: &cancellables) } // TransportPeerEventsDelegate func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) { updatePeers() } // MARK: - Core Update Logic private func updatePeers() { let meshPeers = meshService.currentPeerSnapshots() // If we have no direct links at all, peers should not be marked reachable // "Reachable" means mesh-attached via at least one live link. let hasAnyConnected = meshPeers.contains { $0.isConnected } let favorites = favoritesService.favorites var enrichedPeers: [BitchatPeer] = [] var connected: Set = [] var addedPeerIDs: Set = [] // Phase 1: Add all mesh peers (connected and reachable) for peerInfo in meshPeers { let peerID = peerInfo.peerID guard peerID != meshService.myPeerID else { continue } // Never add self let peer = buildPeerFromMesh( peerInfo: peerInfo, favorites: favorites, meshAttached: hasAnyConnected ) enrichedPeers.append(peer) if peer.isConnected { 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 = PeerID(hexData: favoriteKey) // 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 // Connectivity rank: connected > reachable > others func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) } let lr = rank(lhs), rr = rank(rhs) if lr != rr { return lr > rr } // Then favorites inside same rank 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: [PeerID: BitchatPeer] = [:] for peer in enrichedPeers { newIndex[peer.peerID] = peer if peer.isFavorite { favoritesList.append(peer) } if peer.isMutualFavorite { mutualsList.append(peer) } } // Phase 5: Filter out offline non-mutual peers and update published properties let filtered = enrichedPeers.filter { p in p.isConnected || p.isReachable || p.isMutualFavorite } self.peers = filtered 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( peerInfo: TransportPeerSnapshot, favorites: [Data: FavoritesPersistenceService.FavoriteRelationship], meshAttached: Bool ) -> BitchatPeer { // Determine reachability based on lastSeen and identity trust let now = Date() let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint() let isVerified = fingerprint.map { identityManager.isVerified(fingerprint: $0) } ?? false let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds // A peer is reachable if we recently saw them AND we are attached to the mesh let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached) var peer = BitchatPeer( peerID: peerInfo.peerID, noisePublicKey: peerInfo.noisePublicKey ?? Data(), nickname: peerInfo.nickname, lastSeen: peerInfo.lastSeen, isConnected: peerInfo.isConnected, isReachable: isReachable ) // Check for favorite status if let noiseKey = peerInfo.noisePublicKey, let favoriteStatus = favorites[noiseKey] { peer.favoriteStatus = favoriteStatus peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey } return peer } private func buildPeerFromFavorite( favorite: FavoritesPersistenceService.FavoriteRelationship, peerID: PeerID ) -> BitchatPeer { var peer = BitchatPeer( peerID: peerID, noisePublicKey: favorite.peerNoisePublicKey, nickname: favorite.peerNickname, lastSeen: favorite.lastUpdated, isConnected: false, isReachable: false ) peer.favoriteStatus = favorite peer.nostrPublicKey = favorite.peerNostrPublicKey return peer } // MARK: - Public Methods /// Get peer by ID func getPeer(by peerID: PeerID) -> BitchatPeer? { return peerIndex[peerID] } /// Get peer ID for nickname func getPeerID(for nickname: String) -> PeerID? { for peer in peers { if peer.displayName == nickname || peer.nickname == nickname { return peer.peerID } } return nil } /// Check if peer is blocked func isBlocked(_ peerID: PeerID) -> Bool { // Get fingerprint guard let fingerprint = getFingerprint(for: peerID) else { return false } // Check SecureIdentityStateManager for block status if let identity = identityManager.getSocialIdentity(for: fingerprint) { return identity.isBlocked } return false } /// Toggle favorite status func toggleFavorite(_ peerID: PeerID) { guard let peer = getPeer(by: peerID) else { SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session) return } let wasFavorite = peer.isFavorite // Get the actual nickname for logging and saving var actualNickname = peer.nickname // Debug logging to understand the issue SecureLogger.debug("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", category: .session) if actualNickname.isEmpty { // Try to get from mesh service's current peer list if let meshPeerNickname = meshService.peerNickname(peerID: peerID) { actualNickname = meshPeerNickname SecureLogger.debug("🔍 Got nickname from mesh service: '\(actualNickname)'", category: .session) } } // 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 = idBridge.getNostrPublicKey(for: peer.noisePublicKey) } // Add favorite favoritesService.addFavorite( peerNoisePublicKey: peer.noisePublicKey, peerNostrPublicKey: peerNostrKey, peerNickname: finalNickname ) } // Log the final nickname being saved SecureLogger.debug("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", category: .session) // Send favorite notification to the peer via router (mesh or Nostr) if let router = messageRouter { router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite) } else { // Fallback to mesh-only if router not yet wired 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() } } func getFingerprint(for peerID: PeerID) -> String? { // Check cache first if let cached = fingerprintCache[peerID] { return cached } // Try to get from mesh service if let fingerprint = meshService.getFingerprint(for: 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: Set { connectedPeerIDs } var favoritePeers: Set { Set(favorites.compactMap { getFingerprint(for: $0.peerID) }) } var blockedUsers: Set { Set(peers.compactMap { peer in isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil }) } } ================================================ FILE: bitchat/Services/VerificationService.swift ================================================ import Foundation /// QR verification scaffolding: schema, signing, and basic challenge/response helpers. final class VerificationService { static let shared = VerificationService() // Injected Noise service from the running transport (do NOT create new BLEService) private var noise: NoiseEncryptionService? func configure(with noise: NoiseEncryptionService) { self.noise = noise } /// Encapsulates the data encoded into a verification QR struct VerificationQR: Codable { let v: Int let noiseKeyHex: String let signKeyHex: String let npub: String? let nickname: String let ts: Int64 let nonceB64: String var sigHex: String static let context = "bitchat-verify-v1" /// Canonical bytes used for signature (deterministic ordering) func canonicalBytes() -> Data { var out = Data() func appendField(_ s: String) { let d = s.data(using: .utf8) ?? Data() out.append(UInt8(min(d.count, 255))) out.append(d.prefix(255)) } appendField(Self.context) appendField(String(v)) appendField(noiseKeyHex.lowercased()) appendField(signKeyHex.lowercased()) appendField(npub ?? "") appendField(nickname) appendField(String(ts)) appendField(nonceB64) return out } func toURLString() -> String { var comps = URLComponents() comps.scheme = "bitchat" comps.host = "verify" comps.queryItems = [ URLQueryItem(name: "v", value: String(v)), URLQueryItem(name: "noise", value: noiseKeyHex), URLQueryItem(name: "sign", value: signKeyHex), URLQueryItem(name: "nick", value: nickname), URLQueryItem(name: "ts", value: String(ts)), URLQueryItem(name: "nonce", value: nonceB64), URLQueryItem(name: "sig", value: sigHex) ] + (npub != nil ? [URLQueryItem(name: "npub", value: npub)] : []) return comps.string ?? "" } static func fromURL(_ url: URL) -> VerificationQR? { guard url.scheme == "bitchat", url.host == "verify", let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil } func val(_ name: String) -> String? { items.first(where: { $0.name == name })?.value } guard let vStr = val("v"), let v = Int(vStr), let noise = val("noise"), let sign = val("sign"), let nick = val("nick"), let tsStr = val("ts"), let ts = Int64(tsStr), let nonce = val("nonce"), let sig = val("sig") else { return nil } return VerificationQR(v: v, noiseKeyHex: noise, signKeyHex: sign, npub: val("npub"), nickname: nick, ts: ts, nonceB64: nonce, sigHex: sig) } } // MARK: - Public API /// Build a signed QR string for the current identity func buildMyQRString(nickname: String, npub: String?) -> String? { // Simple short-lived cache to speed up sheet opening struct Cache { static var last: (nick: String, npub: String?, builtAt: Date, value: String)? } if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 { return c.value } guard let noise = noise else { return nil } let noiseKey = noise.getStaticPublicKeyData().hexEncodedString() let signKey = noise.getSigningPublicKeyData().hexEncodedString() let ts = Int64(Date().timeIntervalSince1970) var nonce = Data(count: 16) _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "") let msg = payload.canonicalBytes() guard let sig = noise.signData(msg) else { return nil } let signed = VerificationQR(v: payload.v, noiseKeyHex: payload.noiseKeyHex, signKeyHex: payload.signKeyHex, npub: payload.npub, nickname: payload.nickname, ts: payload.ts, nonceB64: payload.nonceB64, sigHex: sig.hexEncodedString()) let out = signed.toURLString() Cache.last = (nickname, npub, Date(), out) return out } /// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks) func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? { guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil } // Freshness let now = Date().timeIntervalSince1970 if now - Double(qr.ts) > maxAge { return nil } // Verify signature using embedded ed25519 signKey guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil } guard let noise = noise else { return nil } let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey) return ok ? qr : nil } // MARK: - Noise payloads (scaffold only) func buildVerifyChallenge(noiseKeyHex: String, nonceA: Data) -> Data { // TLV: [0x01 len noiseKeyHex ascii] [0x02 len nonceA] var tlv = Data() let n0: [UInt8] = [0x01, UInt8(min(noiseKeyHex.count, 255))] tlv.append(contentsOf: n0) tlv.append(noiseKeyHex.data(using: .utf8)!.prefix(255)) tlv.append(0x02) tlv.append(UInt8(min(nonceA.count, 255))) tlv.append(nonceA.prefix(255)) return NoisePayload(type: .verifyChallenge, data: tlv).encode() } func buildVerifyResponse(noiseKeyHex: String, nonceA: Data) -> Data? { // Sign context: verify-response | noiseKeyHex | nonceA var msg = Data("bitchat-verify-resp-v1".utf8) let nk = noiseKeyHex.data(using: .utf8) ?? Data() msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255)) msg.append(nonceA) guard let noise = noise, let sig = noise.signData(msg) else { return nil } var tlv = Data() tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255)) tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255)) tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255)) return NoisePayload(type: .verifyResponse, data: tlv).encode() } func parseVerifyChallenge(_ data: Data) -> (noiseKeyHex: String, nonceA: Data)? { var idx = 0 func take(_ n: Int) -> Data? { guard idx + n <= data.count else { return nil } let d = data[idx..<(idx+n)] idx += n return Data(d) } // Expect type already stripped; we receive only TLV here // TLV 0x01 noiseKeyHex guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])), let noiseStr = String(data: s1, encoding: .utf8) else { return nil } // TLV 0x02 nonceA guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil } return (noiseStr, nA) } func parseVerifyResponse(_ data: Data) -> (noiseKeyHex: String, nonceA: Data, signature: Data)? { var idx = 0 func take(_ n: Int) -> Data? { guard idx + n <= data.count else { return nil } let d = data[idx..<(idx+n)] idx += n return Data(d) } guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])), let noiseStr = String(data: s1, encoding: .utf8) else { return nil } guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil } guard let t3 = take(1), t3[0] == 0x03, let l3 = take(1), let sig = take(Int(l3[0])) else { return nil } return (noiseStr, nA, sig) } func verifyResponseSignature(noiseKeyHex: String, nonceA: Data, signature: Data, signerPublicKeyHex: String) -> Bool { var msg = Data("bitchat-verify-resp-v1".utf8) let nk = noiseKeyHex.data(using: .utf8) ?? Data() msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255)) msg.append(nonceA) guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false } return noise.verifySignature(signature, for: msg, publicKey: pub) } } ================================================ FILE: bitchat/Sync/GCSFilter.swift ================================================ import Foundation import CryptoKit // Golomb-Coded Set (GCS) filter utilities for sync. // Hashing: // - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID. // - Map to [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas. // Encoding (v1): // - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1. // - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1< Int { let f = max(0.000001, min(0.25, targetFpr)) // ceil(log2(1/f)) let p = Int(ceil(log2(1.0 / f))) return max(1, p) } // Estimate max elements that fit in size bytes: bits per element ~= P + 2 (approx) static func estimateMaxElements(sizeBytes: Int, p: Int) -> Int { let bits = max(8, sizeBytes * 8) let per = max(3, p + 2) return max(1, bits / per) } static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params { let p = deriveP(targetFpr: targetFpr) guard !ids.isEmpty else { return Params(p: p, m: 1, data: Data()) } let cap = estimateMaxElements(sizeBytes: maxBytes, p: p) let selected = Array(ids.prefix(cap)) let range = max(1, hashRange(count: selected.count, p: p)) let modulo = UInt64(range) var mapped = selected .map { h64($0) } .map { mapHash($0, modulo: modulo) } .sorted() mapped = normalizeMappedValues(mapped, modulo: modulo) if mapped.isEmpty { return Params(p: p, m: range, data: Data()) } var encoded = encode(sorted: mapped, p: p) var trimmedCount = mapped.count while encoded.count > maxBytes && trimmedCount > 0 { if trimmedCount == 1 { mapped.removeAll() encoded = Data() break } trimmedCount = max(1, (trimmedCount * 9) / 10) mapped = Array(mapped.prefix(trimmedCount)) encoded = encode(sorted: mapped, p: p) } return Params(p: p, m: range, data: encoded) } static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] { var values: [UInt64] = [] let reader = BitReader(data) var acc: UInt64 = 0 while true { guard let q = reader.readUnary() else { break } guard let r = reader.readBits(count: p) else { break } let x = (UInt64(q) << UInt64(p)) + UInt64(r) + 1 acc &+= x if acc >= UInt64(m) { break } values.append(acc) } return values } static func contains(sortedValues: [UInt64], candidate: UInt64) -> Bool { var lo = 0 var hi = sortedValues.count - 1 while lo <= hi { let mid = (lo + hi) >> 1 let v = sortedValues[mid] if v == candidate { return true } if v < candidate { lo = mid + 1 } else { hi = mid - 1 } } return false } static func bucket(for id: Data, modulus m: UInt32) -> UInt64 { let modulo = UInt64(max(1, m)) guard modulo > 1 else { return 0 } return mapHash(h64(id), modulo: modulo) } private static func h64(_ id16: Data) -> UInt64 { var hasher = SHA256() hasher.update(data: id16) let d = hasher.finalize() let db = Data(d) var x: UInt64 = 0 let take = min(8, db.count) for i in 0.. UInt32 { guard count > 0 else { return 1 } if p >= 64 { return UInt32.max } let multiplier = UInt64(1) << UInt64(p) let (product, overflow) = UInt64(count).multipliedReportingOverflow(by: multiplier) if overflow { return UInt32.max } if product == 0 { return 1 } return product > UInt64(UInt32.max) ? UInt32.max : UInt32(product) } private static func mapHash(_ hash: UInt64, modulo: UInt64) -> UInt64 { guard modulo > 1 else { return 0 } let value = hash % modulo if value == 0 { return 1 } return value } private static func normalizeMappedValues(_ values: [UInt64], modulo: UInt64) -> [UInt64] { guard modulo > 1 else { return [] } guard !values.isEmpty else { return [] } var result: [UInt64] = [] result.reserveCapacity(values.count) var last: UInt64 = 0 for value in values { let normalized = min(value, modulo - 1) if normalized > last { result.append(normalized) last = normalized } } return result } private static func encode(sorted: [UInt64], p: Int) -> Data { let writer = BitWriter() var prev: UInt64 = 0 let mask: UInt64 = (p >= 64) ? ~0 : ((1 << UInt64(p)) - 1) for v in sorted { let delta = v &- prev prev = v let x = delta let q = (x &- 1) >> UInt64(p) let r = (x &- 1) & mask // unary q ones then zero if q > 0 { writer.writeOnes(count: Int(q)) } writer.writeBit(0) writer.writeBits(value: r, count: p) } return writer.toData() } // MARK: - Bit helpers (MSB-first) private final class BitWriter { private var buf = Data() private var cur: UInt8 = 0 private var nbits: Int = 0 func writeBit(_ bit: Int) { // 0 or 1 cur = UInt8((Int(cur) << 1) | (bit & 1)) nbits += 1 if nbits == 8 { buf.append(cur) cur = 0; nbits = 0 } } func writeOnes(count: Int) { guard count > 0 else { return } for _ in 0.. 0 else { return } for i in stride(from: count - 1, through: 0, by: -1) { let bit = Int((value >> UInt64(i)) & 1) writeBit(bit) } } func toData() -> Data { if nbits > 0 { let rem = UInt8(Int(cur) << (8 - nbits)) buf.append(rem) cur = 0; nbits = 0 } return buf } } private final class BitReader { private let data: Data private var idx: Int = 0 private var cur: UInt8 = 0 private var left: Int = 0 init(_ data: Data) { self.data = data if !data.isEmpty { cur = data[0] left = 8 } } func readBit() -> Int? { if idx >= data.count { return nil } let bit = (Int(cur) >> 7) & 1 cur = UInt8((Int(cur) << 1) & 0xFF) left -= 1 if left == 0 { idx += 1 if idx < data.count { cur = data[idx]; left = 8 } } return bit } func readUnary() -> Int? { var q = 0 while true { guard let b = readBit() else { return nil } if b == 1 { q += 1 } else { break } } return q } func readBits(count: Int) -> UInt64? { var v: UInt64 = 0 for _ in 0.. BitchatPacket func getConnectedPeers() -> [PeerID] } private struct PacketStore { private(set) var packets: [String: BitchatPacket] = [:] private(set) var order: [String] = [] mutating func insert(idHex: String, packet: BitchatPacket, capacity: Int) { guard capacity > 0 else { return } if packets[idHex] != nil { packets[idHex] = packet return } packets[idHex] = packet order.append(idHex) while order.count > capacity { let victim = order.removeFirst() packets.removeValue(forKey: victim) } } func allPackets(isFresh: (BitchatPacket) -> Bool) -> [BitchatPacket] { order.compactMap { key in guard let packet = packets[key], isFresh(packet) else { return nil } return packet } } mutating func remove(where shouldRemove: (BitchatPacket) -> Bool) { var nextOrder: [String] = [] for key in order { guard let packet = packets[key] else { continue } if shouldRemove(packet) { packets.removeValue(forKey: key) } else { nextOrder.append(key) } } order = nextOrder } mutating func removeExpired(isFresh: (BitchatPacket) -> Bool) { remove { !isFresh($0) } } } private struct SyncSchedule { let types: SyncTypeFlags let interval: TimeInterval var lastSent: Date } struct Config { var seenCapacity: Int = 1000 // max packets per sync (cap across types) var gcsMaxBytes: Int = 400 // filter size budget (128..1024) var gcsTargetFpr: Double = 0.01 // 1% var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages var maintenanceIntervalSeconds: TimeInterval = 30.0 var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0 var stalePeerTimeoutSeconds: TimeInterval = 60.0 var fragmentCapacity: Int = 600 var fileTransferCapacity: Int = 200 var fragmentSyncIntervalSeconds: TimeInterval = 30.0 var fileTransferSyncIntervalSeconds: TimeInterval = 60.0 var messageSyncIntervalSeconds: TimeInterval = 15.0 } private let myPeerID: PeerID private let config: Config private let requestSyncManager: RequestSyncManager weak var delegate: Delegate? // Storage: broadcast packets by type, and latest announce per sender private var messages = PacketStore() private var fragments = PacketStore() private var fileTransfers = PacketStore() private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] // Timer private var periodicTimer: DispatchSourceTimer? private let queue = DispatchQueue(label: "mesh.sync", qos: .utility) private var lastStalePeerCleanup: Date = .distantPast private var syncSchedules: [SyncSchedule] = [] init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) { self.myPeerID = myPeerID self.config = config self.requestSyncManager = requestSyncManager var schedules: [SyncSchedule] = [] if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) } if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast)) } if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast)) } syncSchedules = schedules } func start() { stop() let timer = DispatchSource.makeTimerSource(queue: queue) let interval = max(0.1, config.maintenanceIntervalSeconds) timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) timer.setEventHandler { [weak self] in self?.performPeriodicMaintenance() } timer.resume() periodicTimer = timer } func stop() { periodicTimer?.cancel(); periodicTimer = nil } func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) { queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in guard let self = self else { return } self.sendRequestSync(to: peerID, types: .publicMessages) if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 { self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.sendRequestSync(to: peerID, types: .fragment) } } if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 { self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.sendRequestSync(to: peerID, types: .fileTransfer) } } } } func onPublicPacketSeen(_ packet: BitchatPacket) { queue.async { [weak self] in self?._onPublicPacketSeen(packet) } } // Helper to check if a packet is within the age threshold private func isPacketFresh(_ packet: BitchatPacket) -> Bool { let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000) // If current time is less than threshold, accept all (handle clock issues gracefully) guard nowMs >= ageThresholdMs else { return true } let cutoffMs = nowMs - ageThresholdMs return packet.timestamp >= cutoffMs } private func isAnnouncementFresh(_ packet: BitchatPacket) -> Bool { guard config.stalePeerTimeoutSeconds > 0 else { return true } let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000) guard nowMs >= timeoutMs else { return true } let cutoffMs = nowMs - timeoutMs return packet.timestamp >= cutoffMs } private func _onPublicPacketSeen(_ packet: BitchatPacket) { guard let messageType = MessageType(rawValue: packet.type) else { return } let isBroadcastRecipient: Bool = { guard let r = packet.recipientID else { return true } return r.count == 8 && r.allSatisfy { $0 == 0xFF } }() switch messageType { case .announce: guard isPacketFresh(packet) else { return } guard isAnnouncementFresh(packet) else { let sender = PeerID(hexData: packet.senderID) removeState(for: sender) return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let sender = PeerID(hexData: packet.senderID) latestAnnouncementByPeer[sender] = (id: idHex, packet: packet) case .message: guard isBroadcastRecipient else { return } guard isPacketFresh(packet) else { return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity)) case .fragment: guard isBroadcastRecipient else { return } guard isPacketFresh(packet) else { return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() fragments.insert(idHex: idHex, packet: packet, capacity: max(1, config.fragmentCapacity)) case .fileTransfer: guard isBroadcastRecipient else { return } guard isPacketFresh(packet) else { return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity)) default: break } } private func sendPeriodicSync(for types: SyncTypeFlags) { // Unicast sync to connected peers to allow RSR attribution if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty { SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync) for peerID in connectedPeers { sendRequestSync(to: peerID, types: types) } } else { // Fallback to broadcast (discovery phase) sendRequestSync(for: types) } } private func sendRequestSync(for types: SyncTypeFlags) { let payload = buildGcsPayload(for: types) let pkt = BitchatPacket( type: MessageType.requestSync.rawValue, senderID: Data(hexString: myPeerID.id) ?? Data(), recipientID: nil, // broadcast timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: 0 // local-only ) let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt delegate?.sendPacket(signed) } private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) { // Register the request for RSR validation requestSyncManager.registerRequest(to: peerID) let payload = buildGcsPayload(for: types) var recipient = Data() var temp = peerID.id while temp.count >= 2 && recipient.count < 8 { let hexByte = String(temp.prefix(2)) if let b = UInt8(hexByte, radix: 16) { recipient.append(b) } temp = String(temp.dropFirst(2)) } let pkt = BitchatPacket( type: MessageType.requestSync.rawValue, senderID: Data(hexString: myPeerID.id) ?? Data(), recipientID: recipient, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: 0 // local-only ) let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt delegate?.sendPacket(to: peerID, packet: signed) } func handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) { queue.async { [weak self] in self?._handleRequestSync(from: peerID, request: request) } } private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) { let requestedTypes = (request.types ?? .publicMessages) // Decode GCS into sorted set and prepare membership checker let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) func mightContain(_ id: Data) -> Bool { let bucket = GCSFilter.bucket(for: id, modulus: request.m) return GCSFilter.contains(sortedValues: sorted, candidate: bucket) } if requestedTypes.contains(.announce) { for (_, pair) in latestAnnouncementByPeer { let (idHex, pkt) = pair guard isPacketFresh(pkt) else { continue } let idBytes = Data(hexString: idHex) ?? Data() if !mightContain(idBytes) { var toSend = pkt toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) } } } if requestedTypes.contains(.message) { let toSendMsgs = messages.allPackets(isFresh: isPacketFresh) for pkt in toSendMsgs { let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) } } } if requestedTypes.contains(.fragment) { let frags = fragments.allPackets(isFresh: isPacketFresh) for pkt in frags { let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) } } } if requestedTypes.contains(.fileTransfer) { let files = fileTransfers.allPackets(isFresh: isPacketFresh) for pkt in files { let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) } } } } // Build REQUEST_SYNC payload using current candidates and GCS params private func buildGcsPayload(for types: SyncTypeFlags) -> Data { var candidates: [BitchatPacket] = [] if types.contains(.announce) { for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) { candidates.append(pair.packet) } } if types.contains(.message) { candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh)) } if types.contains(.fragment) { candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh)) } if types.contains(.fileTransfer) { candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh)) } if candidates.isEmpty { let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr) let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) return req.encode() } // Sort by timestamp desc candidates.sort { $0.timestamp > $1.timestamp } let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr) let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p) let cap: Int if types == .fragment { cap = max(1, config.fragmentCapacity) } else if types == .fileTransfer { cap = max(1, config.fileTransferCapacity) } else { cap = max(1, config.seenCapacity) } let takeN = min(candidates.count, min(nMax, cap)) if takeN <= 0 { let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) return req.encode() } let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) } let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr) let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types) return req.encode() } // Periodic cleanup of expired messages and announcements private func cleanupExpiredMessages() { // Remove expired announcements latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in isPacketFresh(pair.packet) } messages.removeExpired(isFresh: isPacketFresh) fragments.removeExpired(isFresh: isPacketFresh) fileTransfers.removeExpired(isFresh: isPacketFresh) } private func performPeriodicMaintenance(now: Date = Date()) { cleanupExpiredMessages() cleanupStaleAnnouncementsIfNeeded(now: now) requestSyncManager.cleanup() // Cleanup expired sync requests for index in syncSchedules.indices { guard syncSchedules[index].interval > 0 else { continue } if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval { syncSchedules[index].lastSent = now sendPeriodicSync(for: syncSchedules[index].types) } } } private func cleanupStaleAnnouncementsIfNeeded(now: Date) { guard now.timeIntervalSince(lastStalePeerCleanup) >= config.stalePeerCleanupIntervalSeconds else { return } lastStalePeerCleanup = now cleanupStaleAnnouncements(now: now) } private func cleanupStaleAnnouncements(now: Date) { let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000) let nowMs = UInt64(now.timeIntervalSince1970 * 1000) guard nowMs >= timeoutMs else { return } let cutoff = nowMs - timeoutMs let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in pair.packet.timestamp < cutoff ? peerID : nil } guard !stalePeerIDs.isEmpty else { return } for peerKey in stalePeerIDs { removeState(for: peerKey) } } // Explicit removal hook for LEAVE/stale peer func removeAnnouncementForPeer(_ peerID: PeerID) { queue.async { [weak self] in self?.removeState(for: peerID) } } private func removeState(for peerID: PeerID) { _ = latestAnnouncementByPeer.removeValue(forKey: peerID) messages.remove { PeerID(hexData: $0.senderID) == peerID } fragments.remove { PeerID(hexData: $0.senderID) == peerID } fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID } } } #if DEBUG extension GossipSyncManager { func _performMaintenanceSynchronously(now: Date = Date()) { queue.sync { performPeriodicMaintenance(now: now) } } func _hasAnnouncement(for peerID: PeerID) -> Bool { queue.sync { latestAnnouncementByPeer[peerID] != nil } } func _messageCount(for peerID: PeerID) -> Int { queue.sync { messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count } } } #endif ================================================ FILE: bitchat/Sync/PacketIdUtil.swift ================================================ import Foundation import CryptoKit // Deterministic packet ID used for gossip sync membership // ID = first 16 bytes of SHA-256 over: [type | senderID | timestamp | payload] enum PacketIdUtil { static func computeId(_ packet: BitchatPacket) -> Data { var hasher = SHA256() hasher.update(data: Data([packet.type])) hasher.update(data: packet.senderID) var tsBE = packet.timestamp.bigEndian withUnsafeBytes(of: &tsBE) { raw in hasher.update(data: Data(raw)) } hasher.update(data: packet.payload) let digest = hasher.finalize() return Data(digest.prefix(16)) } } ================================================ FILE: bitchat/Sync/RequestSyncManager.swift ================================================ // // RequestSyncManager.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation import BitLogger /// Manages outgoing sync requests and validates incoming responses. /// /// Allows attributing RSR (Request-Sync Response) packets to specific peers /// that we have actively requested sync from. final class RequestSyncManager { private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent) private var pendingRequests: [PeerID: TimeInterval] = [:] private let responseWindow: TimeInterval private let now: () -> TimeInterval init( responseWindow: TimeInterval = 30.0, now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 } ) { self.responseWindow = responseWindow self.now = now } /// Register that we are sending a sync request to a peer. /// - Parameter peerID: The peer we are requesting sync from func registerRequest(to peerID: PeerID) { let now = self.now() queue.async(flags: .barrier) { SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync) self.pendingRequests[peerID] = now } } /// Check if a packet from a peer is a valid response to a sync request. /// /// - Parameters: /// - peerID: The sender of the packet /// - isRSR: Whether the packet is marked as a Request-Sync Response /// - Returns: true if we have a pending request for this peer and the window is open func isValidResponse(from peerID: PeerID, isRSR: Bool) -> Bool { guard isRSR else { return false } return queue.sync { guard let requestTime = pendingRequests[peerID] else { SecureLogger.warning("Received unsolicited RSR packet from \(peerID.id.prefix(8))…", category: .security) return false } let now = self.now() if now - requestTime > responseWindow { SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security) // We don't remove here because we might receive multiple packets for one request return false } return true } } /// Periodic cleanup of expired requests func cleanup() { let now = self.now() queue.async(flags: .barrier) { let originalCount = self.pendingRequests.count self.pendingRequests = self.pendingRequests.filter { _, timestamp in now - timestamp <= self.responseWindow } let removed = originalCount - self.pendingRequests.count if removed > 0 { SecureLogger.debug("Cleaned up \(removed) expired sync requests", category: .sync) } } } var debugPendingRequestCount: Int { queue.sync { pendingRequests.count } } } ================================================ FILE: bitchat/Sync/SyncTypeFlags.swift ================================================ import Foundation /// Bitfield describing which message types are covered by a REQUEST_SYNC round. /// Matches the Android mapping (bit index -> message type). struct SyncTypeFlags: OptionSet { let rawValue: UInt64 init(rawValue: UInt64) { self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes } private static func bitIndex(for type: MessageType) -> Int? { switch type { case .announce: return 0 case .message: return 1 case .leave: return 2 case .noiseHandshake: return 3 case .noiseEncrypted: return 4 case .fragment: return 5 case .requestSync: return 6 case .fileTransfer: return 7 } } private static func type(forBit index: Int) -> MessageType? { switch index { case 0: return .announce case 1: return .message case 2: return .leave case 3: return .noiseHandshake case 4: return .noiseEncrypted case 5: return .fragment case 6: return .requestSync case 7: return .fileTransfer default: return nil } } static let announce = SyncTypeFlags(messageTypes: [.announce]) static let message = SyncTypeFlags(messageTypes: [.message]) static let fragment = SyncTypeFlags(messageTypes: [.fragment]) static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer]) static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message]) init(messageTypes: [MessageType]) { var raw: UInt64 = 0 for type in messageTypes { guard let bit = SyncTypeFlags.bitIndex(for: type) else { continue } raw |= (1 << UInt64(bit)) } self.init(rawValue: raw) } func contains(_ type: MessageType) -> Bool { guard let bit = SyncTypeFlags.bitIndex(for: type) else { return false } return contains(SyncTypeFlags(rawValue: 1 << UInt64(bit))) } func union(_ other: SyncTypeFlags) -> SyncTypeFlags { SyncTypeFlags(rawValue: rawValue | other.rawValue) } func intersection(_ other: SyncTypeFlags) -> SyncTypeFlags { SyncTypeFlags(rawValue: rawValue & other.rawValue) } func toMessageTypes() -> [MessageType] { guard rawValue != 0 else { return [] } var types: [MessageType] = [] for bit in 0..<64 { guard (rawValue & (1 << UInt64(bit))) != 0 else { continue } if let type = SyncTypeFlags.type(forBit: bit) { types.append(type) } } return types } func toData() -> Data? { guard rawValue != 0 else { return nil } var value = rawValue var bytes: [UInt8] = [] while value > 0 && bytes.count < 8 { bytes.append(UInt8(value & 0xFF)) value >>= 8 } while let last = bytes.last, last == 0 { bytes.removeLast() } guard !bytes.isEmpty, bytes.count <= 8 else { return nil } return Data(bytes) } static func decode(_ data: Data) -> SyncTypeFlags? { guard (1...8).contains(data.count) else { return nil } var raw: UInt64 = 0 for (index, byte) in data.enumerated() { raw |= UInt64(byte) << UInt64(index * 8) } return SyncTypeFlags(rawValue: raw) } } ================================================ FILE: bitchat/Utils/Color+Peer.swift ================================================ // // Color+Peer.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import SwiftUI extension Color { private static var peerColorCache: [String: Color] = [:] init(peerSeed: String, isDark: Bool) { let cacheKey = peerSeed + (isDark ? "|dark" : "|light") if let cached = Self.peerColorCache[cacheKey] { self = cached } let h = peerSeed.djb2() var hue = Double(h % 1000) / 1000.0 let orange = 30.0 / 360.0 if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta { hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0) } let sRand = Double((h >> 17) & 0x3FF) / 1023.0 let bRand = Double((h >> 27) & 0x3FF) / 1023.0 let sBase: Double = isDark ? 0.80 : 0.70 let sRange: Double = 0.20 let bBase: Double = isDark ? 0.75 : 0.45 let bRange: Double = isDark ? 0.16 : 0.14 let saturation = min(1.0, max(0.50, sBase + (sRand - 0.5) * sRange)) let brightness = min(1.0, max(0.35, bBase + (bRand - 0.5) * bRange)) let c = Color(hue: hue, saturation: saturation, brightness: brightness) Self.peerColorCache[cacheKey] = c self = c } } ================================================ FILE: bitchat/Utils/CompressionUtil.swift ================================================ // // CompressionUtil.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation import Compression struct CompressionUtil { // Compression threshold - don't compress if data is smaller than this static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes // Compress data using zlib algorithm (most compatible) static func compress(_ data: Data) -> Data? { // Skip compression for small data guard data.count >= compressionThreshold else { return nil } let maxCompressedSize = data.count + (data.count / 255) + 16 let destinationBuffer = UnsafeMutablePointer.allocate(capacity: maxCompressedSize) defer { destinationBuffer.deallocate() } let compressedSize = data.withUnsafeBytes { sourceBuffer in guard let sourcePtr = sourceBuffer.bindMemory(to: UInt8.self).baseAddress else { return 0 } return compression_encode_buffer( destinationBuffer, data.count, sourcePtr, data.count, nil, COMPRESSION_ZLIB ) } guard compressedSize > 0 && compressedSize < data.count else { return nil } return Data(bytes: destinationBuffer, count: compressedSize) } // Decompress zlib compressed data static func decompress(_ compressedData: Data, originalSize: Int) -> Data? { let destinationBuffer = UnsafeMutablePointer.allocate(capacity: originalSize) defer { destinationBuffer.deallocate() } let decompressedSize = compressedData.withUnsafeBytes { sourceBuffer in guard let sourcePtr = sourceBuffer.bindMemory(to: UInt8.self).baseAddress else { return 0 } return compression_decode_buffer( destinationBuffer, originalSize, sourcePtr, compressedData.count, nil, COMPRESSION_ZLIB ) } guard decompressedSize > 0 else { return nil } return Data(bytes: destinationBuffer, count: decompressedSize) } // Helper to check if compression is worth it static func shouldCompress(_ data: Data) -> Bool { // Don't compress if: // 1. Data is too small // 2. Data appears to be already compressed (high entropy) guard data.count >= compressionThreshold else { return false } // Quick uniqueness check — a high diversity of bytes usually means the // payload is already compressed. We only need to know how many unique // values exist rather than keeping full frequency counts. let uniqueByteCount = Set(data).count let sampleSize = min(data.count, 256) let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize) return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes } } ================================================ FILE: bitchat/Utils/Data+SHA256.swift ================================================ // // Data+SHA256.swift // bitchat // // Created by Islam on 26/09/2025. // import struct Foundation.Data import struct CryptoKit.SHA256 extension Data { /// Returns the hex representation of SHA256 hash func sha256Fingerprint() -> String { // Implementation matches existing fingerprint generation in NoiseEncryptionService sha256Hash().hexEncodedString() } /// Returns the SHA256 hash wrapped in Data func sha256Hash() -> Data { Data(SHA256.hash(data: self)) } } ================================================ FILE: bitchat/Utils/FileTransferLimits.swift ================================================ import Foundation /// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios. enum FileTransferLimits { /// Absolute ceiling enforced for any file payload (voice, image, other). static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB /// Voice notes stay small for low-latency relays. static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB /// Compressed images after downscaling should comfortably fit under this budget. static let maxImageBytes: Int = 512 * 1024 // 512 KiB /// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads. static let maxFramedFileBytes: Int = { let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize + BinaryProtocol.senderIDSize + BinaryProtocol.recipientIDSize + BinaryProtocol.signatureSize return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead }() static func isValidPayload(_ size: Int) -> Bool { size <= maxPayloadBytes } } ================================================ FILE: bitchat/Utils/Font+Bitchat.swift ================================================ import SwiftUI /// Provides Dynamic Type aware font helpers that map existing fixed sizes onto /// preferred text styles so the UI scales with user accessibility settings. extension Font { static func bitchatSystem(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> Font { let style = Font.TextStyle.bitchatPreferredStyle(for: size) var font = Font.system(style, design: design) if weight != .regular { font = font.weight(weight) } return font } } private extension Font.TextStyle { static func bitchatPreferredStyle(for size: CGFloat) -> Font.TextStyle { switch size { case ..<11.5: return .caption2 case ..<13.0: return .caption case ..<13.75: return .footnote case ..<15.5: return .subheadline case ..<17.5: return .callout case ..<19.5: return .body case ..<22.5: return .title3 case ..<27.5: return .title2 case ..<34.0: return .title default: return .largeTitle } } } ================================================ FILE: bitchat/Utils/InputValidator.swift ================================================ import Foundation import BitLogger /// Comprehensive input validation for BitChat protocol /// Prevents injection attacks, buffer overflows, and malformed data struct InputValidator { // MARK: - Constants struct Limits { static let maxNicknameLength = 50 // BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom // for headers/padding by limiting user content to 60_000 bytes. static let maxMessageLength = 60_000 } // MARK: - String Content Validation /// Validates and sanitizes user-provided strings used in UI /// /// Rejects strings containing control characters to prevent potential security issues /// and UI rendering problems. This strict approach ensures data integrity at input time. static func validateUserString(_ string: String, maxLength: Int) -> String? { let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } guard trimmed.count <= maxLength else { return nil } // Reject control characters outright instead of rewriting the string. // This prevents injection attacks and ensures consistent UI rendering. let controlChars = CharacterSet.controlCharacters if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) { // Log rejection for monitoring, without exposing actual content for privacy let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count SecureLogger.debug( "Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))", category: .security ) return nil } return trimmed } /// Validates nickname static func validateNickname(_ nickname: String) -> String? { return validateUserString(nickname, maxLength: Limits.maxNicknameLength) } // MARK: - Protocol Field Validation // Note: Message type validation is performed closer to decoding using // MessageType/NoisePayloadType enums; keeping validator free of stale lists. /// Validates timestamp is reasonable (not too far in past or future) /// BCH-01-011: Reduced from ±1 hour to ±5 minutes to limit replay attack window static func validateTimestamp(_ timestamp: Date) -> Bool { let now = Date() // 5 minutes = 300 seconds (industry standard for replay protection) let fiveMinutesAgo = now.addingTimeInterval(-300) let fiveMinutesFromNow = now.addingTimeInterval(300) return timestamp >= fiveMinutesAgo && timestamp <= fiveMinutesFromNow } } ================================================ FILE: bitchat/Utils/MessageDeduplicator.swift ================================================ import Foundation // MARK: - Message Deduplicator (shared) /// Thread-safe deduplicator with LRU eviction and time-based expiry. /// Used for both message ID deduplication (network layer) and content key deduplication (UI layer). final class MessageDeduplicator { private struct Entry: Equatable { let id: String let timestamp: Date } private var entries: [Entry] = [] private var head: Int = 0 private var lookup: [String: Date] = [:] // id -> timestamp for O(1) lookup private let lock = NSLock() private let maxAge: TimeInterval private let maxCount: Int /// Initialize with default config from TransportConfig convenience init() { self.init( maxAge: TransportConfig.messageDedupMaxAgeSeconds, maxCount: TransportConfig.messageDedupMaxCount ) } /// Initialize with custom config for content deduplication init(maxAge: TimeInterval, maxCount: Int) { self.maxAge = maxAge self.maxCount = maxCount } /// Check if message is duplicate and add if not. /// - Parameter id: The message identifier to check. /// - Returns: `true` if the message was already seen, `false` otherwise. func isDuplicate(_ id: String) -> Bool { lock.lock() defer { lock.unlock() } let now = Date() cleanupOldEntries(before: now.addingTimeInterval(-maxAge)) if lookup[id] != nil { return true } entries.append(Entry(id: id, timestamp: now)) lookup[id] = now trimIfNeeded() return false } /// Record an ID with a specific timestamp (for content key tracking) func record(_ id: String, timestamp: Date) { lock.lock() defer { lock.unlock() } if lookup[id] == nil { entries.append(Entry(id: id, timestamp: timestamp)) } lookup[id] = timestamp trimIfNeeded() } /// Add an ID without checking (for announce-back tracking) func markProcessed(_ id: String) { lock.lock() defer { lock.unlock() } if lookup[id] == nil { let now = Date() entries.append(Entry(id: id, timestamp: now)) lookup[id] = now } } /// Check if ID exists without adding func contains(_ id: String) -> Bool { lock.lock() defer { lock.unlock() } return lookup[id] != nil } /// Get timestamp for an ID (for content deduplication time-window checks) func timestampFor(_ id: String) -> Date? { lock.lock() defer { lock.unlock() } return lookup[id] } private func trimIfNeeded() { let activeCount = entries.count - head guard activeCount > maxCount else { return } // Remove down to 75% of maxCount for better amortization let targetCount = (maxCount * 3) / 4 let removeCount = activeCount - targetCount for i in head..<(head + removeCount) { lookup.removeValue(forKey: entries[i].id) } head += removeCount // Compact when head exceeds half the array to reclaim memory if head > entries.count / 2 { entries.removeFirst(head) head = 0 } } /// Clear all entries func reset() { lock.lock() defer { lock.unlock() } entries.removeAll() head = 0 lookup.removeAll() } /// Periodic cleanup of expired entries and memory optimization. func cleanup() { lock.lock() defer { lock.unlock() } cleanupOldEntries(before: Date().addingTimeInterval(-maxAge)) // Shrink capacity if significantly oversized if entries.capacity > maxCount * 2 && entries.count < maxCount { entries.reserveCapacity(maxCount) } } private func cleanupOldEntries(before cutoff: Date) { while head < entries.count, entries[head].timestamp < cutoff { lookup.removeValue(forKey: entries[head].id) head += 1 } // Compact when head exceeds half the array if head > 0 && head > entries.count / 2 { entries.removeFirst(head) head = 0 } } } ================================================ FILE: bitchat/Utils/PeerDisplayNameResolver.swift ================================================ import Foundation /// Resolves a stable display name for peers, adding a short suffix when collisions exist. struct PeerDisplayNameResolver { /// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur. /// - Parameters: /// - peers: Array of tuples (peerID, nickname, isConnected). /// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it. /// - Returns: Map of peerID -> displayName. static func resolve(_ peers: [(peerID: PeerID, nickname: String, isConnected: Bool)], selfNickname: String) -> [PeerID: String] { // Count collisions among connected peers and include our own nickname var counts: [String: Int] = [:] for p in peers where p.isConnected { counts[p.nickname, default: 0] += 1 } counts[selfNickname, default: 0] += 1 var result: [PeerID: String] = [:] for p in peers { var name = p.nickname if p.isConnected, (counts[p.nickname] ?? 0) > 1 { name += "#" + String(p.peerID.id.prefix(4)) } result[p.peerID] = name } return result } } ================================================ FILE: bitchat/Utils/String+DJB2.swift ================================================ // // String+DJB2.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation extension String { func djb2() -> UInt64 { var hash: UInt64 = 5381 for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) } return hash } } ================================================ FILE: bitchat/Utils/String+Nickname.swift ================================================ // // String+Nickname.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation extension String { /// Split a nickname into base and a '#abcd' suffix if present func splitSuffix() -> (String, String) { let name = self.replacingOccurrences(of: "@", with: "") guard name.count >= 5 else { return (name, "") } let suffix = String(name.suffix(5)) if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in ("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c)) }) { let base = String(name.dropLast(5)) return (base, suffix) } return (name, "") } } ================================================ FILE: bitchat/ViewModels/ChatViewModel.swift ================================================ // // ChatViewModel.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // /// /// # ChatViewModel /// /// The central business logic and state management component for BitChat. /// Coordinates between the UI layer and the networking/encryption services. /// /// ## Overview /// ChatViewModel implements the MVVM pattern, serving as the binding layer between /// SwiftUI views and the underlying BitChat services. It manages: /// - Message state and delivery /// - Peer connections and presence /// - Private chat sessions /// - Command processing /// - UI state like autocomplete and notifications /// /// ## Architecture /// The ViewModel acts as: /// - **BitchatDelegate**: Receives messages and events from BLEService /// - **State Manager**: Maintains all UI-relevant state with @Published properties /// - **Command Processor**: Handles IRC-style commands (/msg, /who, etc.) /// - **Message Router**: Directs messages to appropriate chats (public/private) /// /// ## Key Features /// /// ### Message Management /// - Efficient message handling with duplicate detection /// - Maintains separate public and private message queues /// - Limits message history to prevent memory issues (1337 messages) /// - Tracks delivery and read receipts /// /// ### Privacy Features /// - Ephemeral by design - no persistent message storage /// - Supports verified fingerprints for secure communication /// - Blocks messages from blocked users /// - Emergency wipe capability (triple-tap) /// /// ### User Experience /// - Smart autocomplete for mentions and commands /// - Unread message indicators /// - Connection status tracking /// - Favorite peers management /// /// ## Command System /// Supports IRC-style commands: /// - `/nick `: Change nickname /// - `/msg `: Send private message /// - `/who`: List connected peers /// - `/slap `: Fun interaction /// - `/clear`: Clear message history /// - `/help`: Show available commands /// /// ## Performance Optimizations /// - SwiftUI automatically optimizes UI updates /// - Caches expensive computations (encryption status) /// - Debounces autocomplete suggestions /// - Efficient peer list management /// /// ## Thread Safety /// - All @Published properties trigger UI updates on main thread /// - Background operations use proper queue management /// - Atomic operations for critical state updates /// /// ## Usage Example /// ```swift /// let viewModel = ChatViewModel() /// viewModel.nickname = "Alice" /// viewModel.startServices() /// viewModel.sendMessage("Hello, mesh network!") /// ``` /// import BitLogger import Foundation import SwiftUI import Combine import CommonCrypto import CoreBluetooth import Tor #if os(iOS) import UIKit #endif import UniformTypeIdentifiers /// Manages the application state and business logic for BitChat. /// Acts as the primary coordinator between UI components and backend services, /// implementing the BitchatDelegate protocol to handle network events. final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext { // Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled) typealias Patterns = MessageFormattingEngine.Patterns typealias GeoOutgoingContext = (channel: GeohashChannel, event: NostrEvent, identity: NostrIdentity, teleported: Bool) @MainActor var canSendMediaInCurrentContext: Bool { if let peer = selectedPrivateChatPeer { return !(peer.isGeoDM || peer.isGeoChat) } switch activeChannel { case .mesh: return true case .location: return false } } private var publicRateLimiter = MessageRateLimiter( senderCapacity: TransportConfig.uiSenderRateBucketCapacity, senderRefillPerSec: TransportConfig.uiSenderRateBucketRefillPerSec, contentCapacity: TransportConfig.uiContentRateBucketCapacity, contentRefillPerSec: TransportConfig.uiContentRateBucketRefillPerSec ) @MainActor private func normalizedSenderKey(for message: BitchatMessage) -> String { if let spid = message.senderPeerID { if spid.isGeoChat || spid.isGeoDM { let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased() return "nostr:" + full } else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.id.lowercased() { return "noise:" + full } else { return "mesh:" + spid.id.lowercased() } } return "name:" + message.sender.lowercased() } // MARK: - Published Properties @Published var messages: [BitchatMessage] = [] @Published var currentColorScheme: ColorScheme = .light private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed @Published var isConnected = false private var recentlySeenPeers: Set = [] private var lastNetworkNotificationTime = Date.distantPast private var networkResetTimer: Timer? = nil private var networkEmptyTimer: Timer? = nil private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects @Published var nickname: String = "" { didSet { // Trim whitespace whenever nickname is set let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed != nickname { nickname = trimmed } // Update mesh service nickname if it's initialized if !meshService.myPeerID.isEmpty { meshService.setNickname(nickname) } } } // MARK: - Service Delegates let commandProcessor: CommandProcessor let messageRouter: MessageRouter let privateChatManager: PrivateChatManager let unifiedPeerService: UnifiedPeerService let autocompleteService: AutocompleteService let deduplicationService: MessageDeduplicationService // internal for test access // Computed properties for compatibility @MainActor var connectedPeers: Set { unifiedPeerService.connectedPeerIDs } @Published var allPeers: [BitchatPeer] = [] var privateChats: [PeerID: [BitchatMessage]] { get { privateChatManager.privateChats } set { privateChatManager.privateChats = newValue } } var selectedPrivateChatPeer: PeerID? { get { privateChatManager.selectedPeer } set { if let peerID = newValue { privateChatManager.startChat(with: peerID) } 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 } /// Open the most relevant private chat when tapping the toolbar unread icon. /// Prefers the most recently active unread conversation, otherwise the most recent PM. @MainActor func openMostRelevantPrivateChat() { // Pick most recent unread by last message timestamp let unreadSorted = unreadPrivateMessages .map { ($0, privateChats[$0]?.last?.timestamp ?? Date.distantPast) } .sorted { $0.1 > $1.1 } if let target = unreadSorted.first?.0 { startPrivateChat(with: target) return } // Otherwise pick most recent private chat overall let recent = privateChats .map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) } .sorted { $0.ts > $1.ts } if let target = recent.first?.id { startPrivateChat(with: target) } } // var peerIDToPublicKeyFingerprint: [PeerID: String] = [:] private var selectedPrivateChatFingerprint: String? = nil // Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity private var shortIDToNoiseKey: [PeerID: PeerID] = [:] // Resolve full Noise key for a peer's short ID (used by UI header rendering) @MainActor private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> PeerID? { if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped } // Fallback: derive from active Noise session if available if shortPeerID.id.count == 16, let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) { let stable = PeerID(hexData: key) shortIDToNoiseKey[shortPeerID] = stable return stable } return nil } // Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex) @MainActor func getShortIDForNoiseKey(_ fullNoiseKeyHex: PeerID) -> PeerID { guard fullNoiseKeyHex.id.count == 64 else { return fullNoiseKeyHex } // Check known peers for a noise key match if let match = allPeers.first(where: { PeerID(hexData: $0.noisePublicKey) == fullNoiseKeyHex }) { return match.peerID } // Also search cache mapping if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) { return pair.key } return fullNoiseKeyHex } private var peerIndex: [PeerID: BitchatPeer] = [:] // MARK: - Autocomplete Properties @Published var autocompleteSuggestions: [String] = [] @Published var showAutocomplete: Bool = false @Published var autocompleteRange: NSRange? = nil @Published var selectedAutocompleteIndex: Int = 0 // Temporary property to fix compilation @Published var showPasswordPrompt = false // MARK: - Services and Storage let meshService: Transport let idBridge: NostrIdentityBridge let identityManager: SecureIdentityStateManagerProtocol var nostrRelayManager: NostrRelayManager? private let userDefaults = UserDefaults.standard let keychain: KeychainManagerProtocol private let nicknameKey = "bitchat.nickname" // Location channel state (macOS supports manual geohash selection) @Published var activeChannel: ChannelID = .mesh var geoSubscriptionID: String? = nil var geoDmSubscriptionID: String? = nil var currentGeohash: String? = nil var cachedGeohashIdentity: (geohash: String, identity: NostrIdentity)? = nil // Cache current geohash identity var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname // Show Tor status once per app launch var torStatusAnnounced = false // Track whether a Tor restart is pending so we only announce // "tor restarted" after an actual restart, not the first launch. var torRestartPending: Bool = false // Ensure we set up DM subscription only once per app session var nostrHandlersSetup: Bool = false var geoChannelCoordinator: GeoChannelCoordinator? // MARK: - Caches // Caches for expensive computations private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:] // MARK: - Social Features (Delegated to PeerStateManager) @MainActor var favoritePeers: Set { unifiedPeerService.favoritePeers } @MainActor var blockedUsers: Set { unifiedPeerService.blockedUsers } // MARK: - Encryption and Security // Noise Protocol encryption status @Published var peerEncryptionStatus: [PeerID: EncryptionStatus] = [:] @Published var verifiedFingerprints: Set = [] // Set of verified fingerprints @Published var showingFingerprintFor: PeerID? = nil // Currently showing fingerprint sheet for peer // Bluetooth state management @Published var showBluetoothAlert = false @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown // Presentation state for privacy gating @Published var isLocationChannelsSheetPresented: Bool = false @Published var isAppInfoPresented: Bool = false @Published var showScreenshotPrivacyWarning: Bool = false var timelineStore = PublicTimelineStore( meshCap: TransportConfig.meshTimelineCap, geohashCap: TransportConfig.geoTimelineCap ) // Channel activity tracking for background nudges var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time // Geohash participant tracker let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds) // Participants who indicated they teleported (by tag in their events) @Published var teleportedGeo: Set = [] // lowercased pubkey hex // Sampling subscriptions for multiple geohashes (when channel sheet is open) var geoSamplingSubs: [String: String] = [:] // subID -> geohash var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time // MARK: - Message Delivery Tracking // Delivery tracking var cancellables = Set() var transferIdToMessageIDs: [String: [String]] = [:] var messageIDToTransferId: [String: String] = [:] // MARK: - QR Verification (pending state) private struct PendingVerification { let noiseKeyHex: String let signKeyHex: String let nonceA: Data let startedAt: Date var sent: Bool } private var pendingQRVerifications: [PeerID: PendingVerification] = [:] // Last handled challenge nonce per peer to avoid duplicate responses private var lastVerifyNonceByPeer: [PeerID: Data] = [:] // Track when we last received a verify challenge from a peer (fingerprint-keyed) private var lastInboundVerifyChallengeAt: [String: Date] = [:] // key: fingerprint // Throttle mutual verification toasts per fingerprint private var lastMutualToastAt: [String: Date] = [:] // key: fingerprint // MARK: - Public message batching (UI perf) let publicMessagePipeline: PublicMessagePipeline @Published private(set) var isBatchingPublic: Bool = false // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed var sentReadReceipts: Set = [] { // messageID set didSet { // Only persist if there are changes guard oldValue != sentReadReceipts else { return } // Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read) if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) { UserDefaults.standard.set(data, forKey: "sentReadReceipts") } else { SecureLogger.error("❌ Failed to encode read receipts for persistence", category: .session) } } } // Throttle verification response toasts per peer to avoid spam var lastVerifyToastAt: [String: Date] = [:] // Track which GeoDM messages we've already sent a delivery ACK for (by messageID) var sentGeoDeliveryAcks: Set = [] // Track app startup phase to prevent marking old messages as unread private var isStartupPhase = true // Announce Tor initial readiness once per launch to avoid duplicates var torInitialReadyAnnounced: Bool = false // Track Nostr pubkey mappings for unknown senders var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey // MARK: - Initialization @MainActor convenience init( keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol ) { self.init( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager) ) } /// Testable initializer that accepts a Transport dependency. /// Use this initializer for unit testing with MockTransport. @MainActor init( keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, transport: Transport ) { self.keychain = keychain self.idBridge = idBridge self.identityManager = identityManager self.meshService = transport self.publicMessagePipeline = PublicMessagePipeline() // 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(identityManager: identityManager) self.privateChatManager = PrivateChatManager(meshService: meshService) self.unifiedPeerService = UnifiedPeerService(meshService: meshService, idBridge: idBridge, identityManager: identityManager) let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) nostrTransport.senderPeerID = meshService.myPeerID self.messageRouter = MessageRouter(transports: [meshService, nostrTransport]) // Route receipts from PrivateChatManager through MessageRouter self.privateChatManager.messageRouter = self.messageRouter // Allow PrivateChatManager to look up peer info for message consolidation self.privateChatManager.unifiedPeerService = self.unifiedPeerService // Allow UnifiedPeerService to route favorite notifications via mesh/Nostr self.unifiedPeerService.messageRouter = self.messageRouter self.autocompleteService = AutocompleteService() self.deduplicationService = MessageDeduplicationService() // Wire up dependencies self.commandProcessor.contextProvider = self self.participantTracker.configure(context: self) // Subscribe to privateChatManager changes to trigger UI updates privateChatManager.objectWillChange .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) // Subscribe to participantTracker changes to trigger UI updates participantTracker.objectWillChange .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) self.commandProcessor.meshService = meshService loadNickname() loadVerifiedFingerprints() meshService.delegate = self // Log startup info // Log fingerprint after a delay to ensure encryption service is ready DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak self] in if let self = self { _ = self.getMyFingerprint() } } // Set nickname before starting services meshService.setNickname(nickname) // Start mesh service immediately meshService.startServices() publicMessagePipeline.delegate = self publicMessagePipeline.updateActiveChannel(activeChannel) // Check initial Bluetooth state after a brief delay to allow centralManager initialization DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self = self else { return } if let bleService = self.meshService as? BLEService { let state = bleService.getCurrentBluetoothState() self.updateBluetoothState(state) } } // Announce Tor status (geohash-only; do not show in mesh chat). Only when auto-start is allowed. if TorManager.shared.torEnforced && !torStatusAnnounced && TorManager.shared.isAutoStartAllowed() { torStatusAnnounced = true addGeohashOnlySystemMessage( String(localized: "system.tor.starting", comment: "System message when Tor is starting") ) } else if !TorManager.shared.torEnforced && !torStatusAnnounced { torStatusAnnounced = true addGeohashOnlySystemMessage( String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development") ) } // Initialize Nostr relay manager regardless of Tor readiness; connection is controlled elsewhere nostrRelayManager = NostrRelayManager.shared // Attempt to flush any queued outbox (mesh/Nostr routing will gate appropriately) messageRouter.flushAllOutbox() // End startup phase after a short delay Task { @MainActor in try? await Task.sleep(nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000)) self.isStartupPhase = false } // Bind unified peer service's peer list to our published property let peersCancellable = unifiedPeerService.$peers .receive(on: DispatchQueue.main) .sink { [weak self] peers in guard let self = self else { return } // Update peers directly; @Published drives UI updates self.allPeers = peers // Update peer index for O(1) lookups // Deduplicate peers by ID to prevent crash from duplicate keys var uniquePeers: [PeerID: BitchatPeer] = [:] for peer in peers { // Keep the first occurrence of each peer ID if uniquePeers[peer.peerID] == nil { uniquePeers[peer.peerID] = peer } else { SecureLogger.warning("⚠️ Duplicate peer ID detected: \(peer.peerID) (\(peer.displayName))", category: .session) } } self.peerIndex = uniquePeers // Update private chat peer ID if needed when peers change if self.selectedPrivateChatFingerprint != nil { self.updatePrivateChatPeerIfNeeded() } } self.cancellables.insert(peersCancellable) // Resubscribe geohash on relay reconnect if let relayMgr = self.nostrRelayManager { relayMgr.$isConnected .receive(on: DispatchQueue.main) .sink { [weak self] connected in guard let self = self else { return } if connected { Task { @MainActor in // Set up DM handler once on first connect if !self.nostrHandlersSetup { self.setupNostrMessageHandling() self.nostrHandlersSetup = true } self.resubscribeCurrentGeohash() // Re-init sampling for regional + bookmarked geohashes after reconnect self.geoChannelCoordinator?.refreshSampling() } } } .store(in: &self.cancellables) } // Set up Noise encryption callbacks setupNoiseCallbacks() TransferProgressManager.shared.publisher .receive(on: DispatchQueue.main) .sink { [weak self] event in self?.handleTransferEvent(event) } .store(in: &cancellables) geoChannelCoordinator = GeoChannelCoordinator( onChannelSwitch: { [weak self] channel in self?.switchLocationChannel(to: channel) }, beginSampling: { [weak self] geohashes in self?.beginGeohashSampling(for: geohashes) }, endSampling: { [weak self] in self?.endGeohashSampling() } ) // Track teleport flag changes to keep our own teleported marker in sync with regional status LocationChannelManager.shared.$teleported .receive(on: DispatchQueue.main) .sink { [weak self] isTeleported in guard let self = self else { return } Task { @MainActor in guard case .location(let ch) = self.activeChannel, let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return } let key = id.publicKeyHex.lowercased() let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } if isTeleported && hasRegional && !inRegional { self.teleportedGeo = self.teleportedGeo.union([key]) } else { self.teleportedGeo.remove(key) } } } .store(in: &cancellables) // Request notification permission (guards test environment internally) NotificationService.shared.requestAuthorization() // Listen for favorite status changes NotificationCenter.default.addObserver( self, selector: #selector(handleFavoriteStatusChanged), name: .favoriteStatusChanged, object: nil ) // Listen for peer status updates to refresh UI NotificationCenter.default.addObserver( self, selector: #selector(handlePeerStatusUpdate), name: Notification.Name("peerStatusUpdated"), object: nil ) // When app becomes active, send read receipts for visible messages #if os(macOS) NotificationCenter.default.addObserver( self, selector: #selector(appDidBecomeActive), name: NSApplication.didBecomeActiveNotification, object: nil ) // Add app lifecycle observers to save data NotificationCenter.default.addObserver( self, selector: #selector(appWillResignActive), name: NSApplication.willResignActiveNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(applicationWillTerminate), name: NSApplication.willTerminateNotification, object: nil ) // Tor lifecycle notifications: inform user when Tor restarts and when ready (macOS) NotificationCenter.default.addObserver( self, selector: #selector(handleTorWillRestart), name: .TorWillRestart, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(handleTorDidBecomeReady), name: .TorDidBecomeReady, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(handleTorWillStart), name: .TorWillStart, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(handleTorPreferenceChanged(_:)), name: .TorUserPreferenceChanged, object: nil ) #else NotificationCenter.default.addObserver( self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil ) // Resubscribe geohash on app foreground // Resubscribe handled via appDidBecomeActive selector // Add screenshot detection for iOS NotificationCenter.default.addObserver( self, selector: #selector(userDidTakeScreenshot), name: UIApplication.userDidTakeScreenshotNotification, object: nil ) // Add app lifecycle observers to save data NotificationCenter.default.addObserver( self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(applicationWillTerminate), name: UIApplication.willTerminateNotification, object: nil ) // Tor lifecycle notifications: inform user when Tor restarts and when ready NotificationCenter.default.addObserver( self, selector: #selector(handleTorWillRestart), name: .TorWillRestart, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(handleTorDidBecomeReady), name: .TorDidBecomeReady, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(handleTorWillStart), name: .TorWillStart, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(handleTorPreferenceChanged(_:)), name: .TorUserPreferenceChanged, object: nil ) #endif } // MARK: - Deinitialization deinit { // No need to force UserDefaults synchronization } // MARK: - Nickname Management private func loadNickname() { if let savedNickname = userDefaults.string(forKey: nicknameKey) { // Trim whitespace when loading nickname = savedNickname.trimmingCharacters(in: .whitespacesAndNewlines) } else { nickname = "anon\(Int.random(in: 1000...9999))" saveNickname() } } func saveNickname() { userDefaults.set(nickname, forKey: nicknameKey) // Persist nickname; no need to force synchronize // Send announce with new nickname to all peers meshService.sendBroadcastAnnounce() } func validateAndSaveNickname() { // Trim whitespace from nickname let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) // Check if nickname is empty after trimming if trimmed.isEmpty { nickname = "anon\(Int.random(in: 1000...9999))" } else { nickname = trimmed } saveNickname() } // MARK: - Favorites Management // MARK: - Blocked Users Management (Delegated to PeerStateManager) /// Check if a peer has unread messages, including messages stored under stable Noise keys and temporary Nostr peer IDs @MainActor func hasUnreadMessages(for peerID: PeerID) -> Bool { // First check direct unread messages if unreadPrivateMessages.contains(peerID) { return true } // Check if messages are stored under the stable Noise key hex if let peer = unifiedPeerService.getPeer(by: peerID) { let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) if unreadPrivateMessages.contains(noiseKeyHex) { return true } // Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey if let nostrHex = peer.nostrPublicKey { let convKey = PeerID(nostr_: nostrHex) if unreadPrivateMessages.contains(convKey) { return true } } } // Get the peer's nickname to check for temporary Nostr peer IDs let peerNickname = meshService.peerNickname(peerID: peerID)?.lowercased() ?? "" // Check if any temporary Nostr peer IDs have unread messages from this nickname for unreadPeerID in unreadPrivateMessages { if unreadPeerID.isGeoDM { // 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: PeerID) { // 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 let noisePublicKey = peerID.noiseKey { // 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 }?.peerID if let ephemeralID = ephemeralPeerID { // Found the ephemeral peer, use normal toggle unifiedPeerService.toggleFavorite(ephemeralID) // Also trigger UI update objectWillChange.send() } else { // No ephemeral peer found, directly toggle via FavoritesPersistenceService let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) let wasFavorite = currentStatus?.isFavorite ?? false 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 ?? idBridge.getNostrPublicKey(for: noisePublicKey) FavoritesPersistenceService.shared.addFavorite( peerNoisePublicKey: noisePublicKey, peerNostrPublicKey: nostrKey, peerNickname: finalNickname ) } // Trigger UI update objectWillChange.send() // 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) } } } 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: PeerID) -> Bool { // Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars) if let noisePublicKey = peerID.noiseKey { // 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 } } return false } // MARK: - Public Key and Identity Management @MainActor func isPeerBlocked(_ peerID: PeerID) -> Bool { return unifiedPeerService.isBlocked(peerID) } // Helper method to find current peer ID for a fingerprint @MainActor private func getCurrentPeerIDForFingerprint(_ fingerprint: String) -> PeerID? { // Search through all connected peers to find the one with matching fingerprint for peerID in connectedPeers { if let mappedFingerprint = peerIDToPublicKeyFingerprint[peerID], mappedFingerprint == fingerprint { return peerID } } return nil } // Helper method to update selectedPrivateChatPeer if fingerprint matches @MainActor private func updatePrivateChatPeerIfNeeded() { guard let chatFingerprint = selectedPrivateChatFingerprint else { return } // Find current peer ID for the fingerprint if let currentPeerID = getCurrentPeerIDForFingerprint(chatFingerprint) { // Update the selected peer if it's different if let oldPeerID = selectedPrivateChatPeer, oldPeerID != currentPeerID { // Migrate messages from old peer ID to new peer ID if let oldMessages = privateChats[oldPeerID] { var chats = privateChats if chats[currentPeerID] == nil { chats[currentPeerID] = [] } chats[currentPeerID]?.append(contentsOf: oldMessages) // Sort by timestamp chats[currentPeerID]?.sort { $0.timestamp < $1.timestamp } // Remove duplicates var seen = Set() 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 } // Migrate unread status if unreadPrivateMessages.contains(oldPeerID) { unreadPrivateMessages.remove(oldPeerID) unreadPrivateMessages.insert(currentPeerID) } selectedPrivateChatPeer = currentPeerID // Schedule UI update for encryption status change // UI will update automatically // Also refresh the peer list to update encryption status Task { @MainActor in // UnifiedPeerService updates automatically via subscriptions } } else if selectedPrivateChatPeer == nil { // Just set the peer ID if we don't have one selectedPrivateChatPeer = currentPeerID // UI will update automatically } // Clear unread messages for the current peer ID unreadPrivateMessages.remove(currentPeerID) } } // MARK: - Message Sending /// Sends a message through the BitChat network. /// - Parameter content: The message content to send /// - Note: Automatically handles command processing if content starts with '/' /// Routes to private chat if one is selected, otherwise broadcasts @MainActor func sendMessage(_ content: String) { // Ignore messages that are empty or whitespace-only to prevent blank lines let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } // Check for commands if content.hasPrefix("/") { Task { @MainActor in handleCommand(content) } return } if selectedPrivateChatPeer != nil { // Update peer ID in case it changed due to reconnection updatePrivateChatPeerIfNeeded() if let selectedPeer = selectedPrivateChatPeer { sendPrivateMessage(content, to: selectedPeer) } return } // Parse mentions from the content (use original content for user intent) let mentions = parseMentions(from: content) var geoContext: GeoOutgoingContext? = nil // Add message to local display var displaySender = nickname var localSenderPeerID = meshService.myPeerID var messageID: String? = nil var messageTimestamp = Date() switch activeChannel { case .mesh: break case .location(let ch): do { let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) let suffix = String(identity.publicKeyHex.suffix(4)) displaySender = nickname + "#" + suffix localSenderPeerID = PeerID(nostr: identity.publicKeyHex) let teleported = LocationChannelManager.shared.teleported let event = try NostrProtocol.createEphemeralGeohashEvent( content: trimmed, geohash: ch.geohash, senderIdentity: identity, nickname: nickname, teleported: teleported ) messageID = event.id messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) geoContext = (channel: ch, event: event, identity: identity, teleported: teleported) } catch { SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) addSystemMessage( String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") ) return } } let message = BitchatMessage( id: messageID, sender: displaySender, content: trimmed, timestamp: messageTimestamp, isRelay: false, senderPeerID: localSenderPeerID, mentions: mentions.isEmpty ? nil : mentions ) timelineStore.append(message, to: activeChannel) refreshVisibleMessages(from: activeChannel) // Update content LRU for near-dup detection let ckey = deduplicationService.normalizedContentKey(message.content) deduplicationService.recordContentKey(ckey, timestamp: message.timestamp) trimMessagesIfNeeded() // UI updates automatically via @Published var messages updateChannelActivityTimeThenSend(content: content, trimmed: trimmed, mentions: mentions, geoContext: geoContext, messageID: message.id, timestamp: message.timestamp) } private func updateChannelActivityTimeThenSend(content: String, trimmed: String, mentions: [String], geoContext: GeoOutgoingContext?, messageID: String, timestamp: Date) { switch activeChannel { case .mesh: lastPublicActivityAt["mesh"] = Date() // Send via mesh with mentions meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp) case .location(let ch): lastPublicActivityAt["geo:\(ch.geohash)"] = Date() guard let context = geoContext, context.channel.geohash == ch.geohash else { SecureLogger.error("Geo: missing send context for \(ch.geohash)", category: .session) addSystemMessage( String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") ) return } // Send to geohash channel via Nostr ephemeral Task { @MainActor in self.sendGeohash(context: context) } } } // MARK: - Geohash Participants @MainActor func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool { guard let peerID else { return false } if peerID == meshService.myPeerID { return true } guard peerID.isGeoDM || peerID.isGeoChat else { return false } if let mapped = nostrKeyMapping[peerID]?.lowercased(), let gh = currentGeohash, let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) { if mapped == myIdentity.publicKeyHex.lowercased() { return true } } if let gh = currentGeohash, let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) { if peerID == PeerID(nostr: myIdentity.publicKeyHex) { return true } let suffix = myIdentity.publicKeyHex.suffix(4) let expected = (nickname + "#" + suffix).lowercased() if let display = displayName?.lowercased(), display == expected { return true } } return false } // MARK: - Public helpers /// Published geohash people list for SwiftUI observation var geohashPeople: [GeoPerson] { participantTracker.visiblePeople } /// Return the current, pruned, sorted people list for the active geohash without mutating state. @MainActor func visibleGeohashPeople() -> [GeoPerson] { participantTracker.getVisiblePeople() } /// CommandContextProvider conformance - returns visible geo participants func getVisibleGeoParticipants() -> [CommandGeoParticipant] { visibleGeohashPeople().map { CommandGeoParticipant(id: $0.id, displayName: $0.displayName) } } /// Returns the current participant count for a specific geohash, using the 5-minute activity window. @MainActor func geohashParticipantCount(for geohash: String) -> Int { participantTracker.participantCount(for: geohash) } // MARK: - GeohashParticipantContext Protocol func displayNameForPubkey(_ pubkeyHex: String) -> String { displayNameForNostrPubkey(pubkeyHex) } func isBlocked(_ pubkeyHexLowercased: String) -> Bool { identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) } // Geohash block helpers @MainActor func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool { return identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) } @MainActor func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { let hex = pubkeyHexLowercased.lowercased() identityManager.setNostrBlocked(hex, isBlocked: true) // Remove from participants for all geohashes participantTracker.removeParticipant(pubkeyHex: hex) // Remove their public messages from current geohash timeline and visible list if let gh = currentGeohash { let predicate: (BitchatMessage) -> Bool = { [self] msg in guard let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat else { return false } if let full = self.nostrKeyMapping[spid]?.lowercased() { return full == hex } return false } timelineStore.removeMessages(in: gh, where: predicate) if case .location = activeChannel { messages.removeAll(where: predicate) } } // Remove geohash DM conversation if exists let convKey = PeerID(nostr_: hex) if privateChats[convKey] != nil { privateChats.removeValue(forKey: convKey) unreadPrivateMessages.remove(convKey) } // Remove mapping keys pointing to this pubkey to avoid accidental resolution for (key, value) in self.nostrKeyMapping where value.lowercased() == hex { self.nostrKeyMapping.removeValue(forKey: key) } addSystemMessage( String( format: String(localized: "system.geohash.blocked", comment: "System message shown when a user is blocked in geohash chats"), locale: .current, displayName ) ) } @MainActor func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false) addSystemMessage( String( format: String(localized: "system.geohash.unblocked", comment: "System message shown when a user is unblocked in geohash chats"), locale: .current, displayName ) ) } func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { let suffix = String(pubkeyHex.suffix(4)) // If this is our per-geohash identity, use our nickname if let gh = currentGeohash, let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh) { if myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() { return nickname + "#" + suffix } } // If we have a known nickname tag for this pubkey, use it if let nick = geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty { return nick + "#" + suffix } // Otherwise, anonymous with collision-resistant suffix return "anon#\(suffix)" } // MARK: - Media Transfers private enum MediaSendError: Error { case encodingFailed case tooLarge case copyFailed } func currentPublicSender() -> (name: String, peerID: PeerID) { var displaySender = nickname var senderPeerID = meshService.myPeerID if case .location(let ch) = activeChannel, let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { let suffix = String(identity.publicKeyHex.suffix(4)) displaySender = nickname + "#" + suffix senderPeerID = PeerID(nostr: identity.publicKeyHex) } return (displaySender, senderPeerID) } @MainActor func nicknameForPeer(_ peerID: PeerID) -> String { if let name = meshService.peerNickname(peerID: peerID) { return name } if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), !favorite.peerNickname.isEmpty { return favorite.peerNickname } if let noiseKey = Data(hexString: peerID.id), let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), !favorite.peerNickname.isEmpty { return favorite.peerNickname } return "user" } @MainActor func removeMessage(withID messageID: String, cleanupFile: Bool = false) { var removedMessage: BitchatMessage? if let idx = messages.firstIndex(where: { $0.id == messageID }) { removedMessage = messages.remove(at: idx) } if let storeRemoved = timelineStore.removeMessage(withID: messageID) { removedMessage = removedMessage ?? storeRemoved } var chats = privateChats for (peerID, items) in chats { let filtered = items.filter { $0.id != messageID } if filtered.count != items.count { if filtered.isEmpty { chats.removeValue(forKey: peerID) } else { chats[peerID] = filtered } if removedMessage == nil { removedMessage = items.first(where: { $0.id == messageID }) } } } privateChats = chats if cleanupFile, let message = removedMessage { cleanupLocalFile(forMessage: message) } objectWillChange.send() } /// Add a local system message to a private chat (no network send) @MainActor func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { let systemMessage = BitchatMessage( sender: "system", content: content, timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: true, recipientNickname: meshService.peerNickname(peerID: peerID), senderPeerID: meshService.myPeerID ) if privateChats[peerID] == nil { privateChats[peerID] = [] } privateChats[peerID]?.append(systemMessage) objectWillChange.send() } // MARK: - Bluetooth State Management /// Updates the Bluetooth state and shows appropriate alerts /// - Parameter state: The current Bluetooth manager state @MainActor func updateBluetoothState(_ state: CBManagerState) { bluetoothState = state switch state { case .poweredOff: bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off") showBluetoothAlert = true case .unauthorized: bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing") showBluetoothAlert = true case .unsupported: bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support") showBluetoothAlert = true case .poweredOn: // Hide alert when Bluetooth is powered on showBluetoothAlert = false bluetoothAlertMessage = "" case .unknown, .resetting: // Don't show alerts for transient states showBluetoothAlert = false @unknown default: showBluetoothAlert = false } } // MARK: - Private Chat Management /// Initiates a private chat session with a peer. /// - Parameter peerID: The peer's ID to start chatting with /// - Note: Switches the UI to private chat mode and loads message history @MainActor func startPrivateChat(with peerID: PeerID) { // Safety check: Don't allow starting chat with ourselves if peerID == meshService.myPeerID { return } let peerNickname = meshService.peerNickname(peerID: peerID) ?? "unknown" // Check if the peer is blocked if unifiedPeerService.isBlocked(peerID) { addSystemMessage( String( format: String(localized: "system.chat.blocked", comment: "System message when starting chat fails because peer is blocked"), locale: .current, peerNickname ) ) return } // Check mutual favorites for offline messaging if let peer = unifiedPeerService.getPeer(by: peerID), peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { addSystemMessage( String( format: String(localized: "system.chat.requires_favorite", comment: "System message when mutual favorite requirement blocks chat"), locale: .current, peerNickname ) ) return } // Consolidate messages from different peer ID representations (stable Noise key, temp Nostr IDs) // Pass persisted sentReadReceipts to correctly identify already-read messages after app restart _ = privateChatManager.consolidateMessages(for: peerID, peerNickname: peerNickname, persistedReadReceipts: sentReadReceipts) // Trigger handshake if needed (mesh peers only). Skip for Nostr geohash conv keys. if !peerID.isGeoDM && !peerID.isGeoChat { let sessionState = meshService.getNoiseSessionState(for: peerID) switch sessionState { case .none, .failed: meshService.triggerHandshake(with: peerID) case .handshakeQueued, .handshaking, .established: break } } else { SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session) } // Sync read receipt tracking to prevent duplicates privateChatManager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: nickname, externalReceipts: &sentReadReceipts) privateChatManager.startChat(with: peerID) // Also mark messages as read for Nostr ACKs // This ensures read receipts are sent even for consolidated messages markPrivateMessagesAsRead(from: peerID) } func endPrivateChat() { selectedPrivateChatPeer = nil selectedPrivateChatFingerprint = nil } @MainActor @objc private func handlePeerStatusUpdate(_ notification: Notification) { // Update private chat peer if needed when peer status changes updatePrivateChatPeerIfNeeded() } @objc private func handleFavoriteStatusChanged(_ notification: Notification) { guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return } Task { @MainActor in // Handle noise key updates if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool, isKeyUpdate, let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data { let oldPeerID = PeerID(hexData: oldKey) let newPeerID = PeerID(hexData: peerPublicKey) // If we have a private chat open with the old peer ID, update it to the new one if selectedPrivateChatPeer == oldPeerID { SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session) // Transfer private chat messages to new peer ID if let messages = privateChats[oldPeerID] { var chats = privateChats chats[newPeerID] = messages chats.removeValue(forKey: oldPeerID) privateChats = chats // Trigger setter } // Transfer unread status if unreadPrivateMessages.contains(oldPeerID) { unreadPrivateMessages.remove(oldPeerID) unreadPrivateMessages.insert(newPeerID) } // Update selected peer selectedPrivateChatPeer = newPeerID // Update fingerprint tracking if needed if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] { peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID) peerIDToPublicKeyFingerprint[newPeerID] = fingerprint selectedPrivateChatFingerprint = fingerprint } // Schedule UI refresh // UI will update automatically } else { // Even if the chat isn't open, migrate any existing private chat data if let messages = privateChats[oldPeerID] { SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session) var chats = privateChats chats[newPeerID] = messages chats.removeValue(forKey: oldPeerID) privateChats = chats // Trigger setter } // Transfer unread status if unreadPrivateMessages.contains(oldPeerID) { unreadPrivateMessages.remove(oldPeerID) unreadPrivateMessages.insert(newPeerID) } // Update fingerprint mapping if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] { peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID) peerIDToPublicKeyFingerprint[newPeerID] = fingerprint } } } // First check if this is a peer ID update for our current chat updatePrivateChatPeerIfNeeded() // Then handle favorite/unfavorite messages if applicable if let isFavorite = notification.userInfo?["isFavorite"] as? Bool { let peerID = PeerID(hexData: peerPublicKey) let action = isFavorite ? "favorited" : "unfavorited" // Find peer nickname let peerNickname: String if let nickname = meshService.peerNickname(peerID: peerID) { peerNickname = nickname } else if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) { peerNickname = favorite.peerNickname } else { peerNickname = "Unknown" } // Create system message let systemMessage = BitchatMessage( id: UUID().uuidString, sender: "System", content: "\(peerNickname) \(action) you", timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: false, recipientNickname: nil, senderPeerID: nil, mentions: nil ) // Add to message stream addMessage(systemMessage) // Update peer manager to refresh UI // UnifiedPeerService updates automatically via subscriptions } } } // MARK: - App Lifecycle @MainActor @objc private func appDidBecomeActive() { // Check Bluetooth state and show alert if needed if let bleService = meshService as? BLEService { let currentState = bleService.getCurrentBluetoothState() updateBluetoothState(currentState) } // When app becomes active, send read receipts for visible private chat if let peerID = selectedPrivateChatPeer { // Try immediately self.markPrivateMessagesAsRead(from: peerID) // And again with a delay DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { self.markPrivateMessagesAsRead(from: peerID) } } // Subscriptions will be resent after connections come back up } @MainActor @objc private func userDidTakeScreenshot() { // Respect privacy: do not broadcast screenshots taken from non-chat sheets if isLocationChannelsSheetPresented { // Show a warning about sharing location screenshots publicly showScreenshotPrivacyWarning = true return } if isAppInfoPresented { // Silently ignore screenshots of app info return } // Send screenshot notification based on current context let screenshotMessage = "* \(nickname) took a screenshot *" if let peerID = selectedPrivateChatPeer { // In private chat - send to the other person if let peerNickname = meshService.peerNickname(peerID: peerID) { // Only send screenshot notification if we have an established session // This prevents triggering handshake requests for screenshot notifications let sessionState = meshService.getNoiseSessionState(for: peerID) switch sessionState { case .established: // Send the message directly without going through sendPrivateMessage to avoid local echo messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString) case .none, .failed, .handshakeQueued, .handshaking: // Don't send screenshot notification if no session exists SecureLogger.debug("Skipping screenshot notification to \(peerID) - no established session", category: .security) } } // Show local notification immediately as system message (only in chat) let localNotification = BitchatMessage( sender: "system", content: "you took a screenshot", timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: true, recipientNickname: meshService.peerNickname(peerID: peerID), senderPeerID: meshService.myPeerID ) var chats = privateChats if chats[peerID] == nil { chats[peerID] = [] } chats[peerID]?.append(localNotification) privateChats = chats // Trigger setter } else { // In public chat - send to active public channel switch activeChannel { case .mesh: meshService.sendMessage(screenshotMessage, mentions: [], messageID: UUID().uuidString, timestamp: Date()) case .location(let ch): Task { @MainActor in do { let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) let event = try NostrProtocol.createEphemeralGeohashEvent( content: screenshotMessage, geohash: ch.geohash, senderIdentity: identity, nickname: self.nickname, teleported: LocationChannelManager.shared.teleported ) let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) if targetRelays.isEmpty { SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session) } else { NostrRelayManager.shared.sendEvent(event, to: targetRelays) } // Track ourselves as active participant self.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) } catch { SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session) self.addSystemMessage( String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") ) } } } // Show local notification immediately as system message (only in chat) let localNotification = BitchatMessage( sender: "system", content: "you took a screenshot", timestamp: Date(), isRelay: false ) // Add system message addMessage(localNotification) } } @objc private func appWillResignActive() { // No-op; avoid forcing synchronize on resign } /// Save identity state without stopping services (for backgrounding) func saveIdentityState() { // Force save any pending identity changes (verifications, favorites, etc) identityManager.forceSave() // Verify identity key is still there _ = keychain.verifyIdentityKeyExists() } @objc func applicationWillTerminate() { // Send leave message to all peers meshService.stopServices() // Save identity state saveIdentityState() } @MainActor private func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID, originalTransport: String? = nil) { // First, try to resolve the current peer ID in case they reconnected with a new ID var actualPeerID = peerID // Check if this peer ID exists in current nicknames if meshService.peerNickname(peerID: peerID) == nil { // Peer not found with this ID, try to find by fingerprint or nickname if let oldNoiseKey = Data(hexString: peerID.id), let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: oldNoiseKey) { let peerNickname = favoriteStatus.peerNickname // Search for the current peer ID with the same nickname for (currentPeerID, currentNickname) in meshService.getPeerNicknames() { if currentNickname == peerNickname { SecureLogger.info("📖 Resolved updated peer ID for read receipt: \(peerID) -> \(currentPeerID)", category: .session) actualPeerID = currentPeerID break } } } } // If this originated over Nostr, skip (handled by Nostr code paths) if originalTransport == "nostr" { return } // Use router to decide (mesh if reachable, else Nostr if available) messageRouter.sendReadReceipt(receipt, to: actualPeerID) } @MainActor func markPrivateMessagesAsRead(from peerID: PeerID) { privateChatManager.markAsRead(from: peerID) // Handle GeoDM (nostr_*) read receipts directly via per-geohash identity if peerID.isGeoDM, let recipientHex = nostrKeyMapping[peerID], case .location(let ch) = LocationChannelManager.shared.selectedChannel, let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { let messages = privateChats[peerID] ?? [] for message in messages where message.senderPeerID == peerID && !message.isRelay { if !sentReadReceipts.contains(message.id) { SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…", category: .session) let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id) sentReadReceipts.insert(message.id) } } return } // Get the peer's Noise key to check for Nostr messages var noiseKeyHex: PeerID? = nil var peerNostrPubkey: String? = nil // First check if peerID is already a hex Noise key if let noiseKey = Data(hexString: peerID.id), 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 = PeerID(hexData: peer.noisePublicKey) 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 peerNostrPubkey != nil { // 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) { // Use stable Noise key hex if available; else fall back to peerID let recipPeer = peerID.isHex ? peerID : (unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID) let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname) messageRouter.sendReadReceipt(receipt, to: recipPeer) sentReadReceipts.insert(message.id) } } } } } @MainActor func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { var combined: [BitchatMessage] = [] // Gather messages under the ephemeral peer ID if let ephemeralMessages = privateChats[peerID] { combined.append(contentsOf: ephemeralMessages) } // Also include messages stored under the stable Noise key (Nostr path) if let peer = unifiedPeerService.getPeer(by: peerID) { let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex] { combined.append(contentsOf: nostrMessages) } } // De-duplicate by message ID: keep the item with the most advanced delivery status. // This prevents duplicate IDs causing LazyVStack warnings and blank rows, and ensures // we show the row whose status has already progressed to delivered/read. func statusRank(_ s: DeliveryStatus?) -> Int { guard let s = s else { return 0 } switch s { case .failed: return 1 case .sending: return 2 case .sent: return 3 case .partiallyDelivered: return 4 case .delivered: return 5 case .read: return 6 } } var bestByID: [String: BitchatMessage] = [:] for msg in combined { if let existing = bestByID[msg.id] { let lhs = statusRank(existing.deliveryStatus) let rhs = statusRank(msg.deliveryStatus) if rhs > lhs || (rhs == lhs && msg.timestamp > existing.timestamp) { bestByID[msg.id] = msg } } else { bestByID[msg.id] = msg } } // Return chronologically sorted, de-duplicated list return bestByID.values.sorted { $0.timestamp < $1.timestamp } } @MainActor func getPeerIDForNickname(_ nickname: String) -> PeerID? { // When in a geohash channel, allow resolving by geohash participant nickname switch LocationChannelManager.shared.selectedChannel { case .location: // If a disambiguation suffix is present (e.g., "name#abcd"), try exact displayName match first if nickname.contains("#") { if let person = visibleGeohashPeople().first(where: { $0.displayName == nickname }) { let convKey = PeerID(nostr_: person.id) nostrKeyMapping[convKey] = person.id return convKey } } let base: String = { if let hashIndex = nickname.firstIndex(of: "#") { return String(nickname[.. nickname) if let pub = geoNicknames.first(where: { (_, nick) in nick.lowercased() == base })?.key { let convKey = PeerID(nostr_: pub) nostrKeyMapping[convKey] = pub return convKey } case .mesh: break } // Fallback to mesh nickname resolution return unifiedPeerService.getPeerID(for: nickname) } // MARK: - Emergency Functions // PANIC: Emergency data clearing for activist safety @MainActor func panicClearAllData() { // Messages are processed immediately - nothing to flush // Clear all messages messages.removeAll() privateChatManager.privateChats.removeAll() privateChatManager.unreadMessages.removeAll() // Delete all keychain data (including Noise and Nostr keys) _ = keychain.deleteAllKeychainData() // Clear UserDefaults identity data userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey") userDefaults.removeObject(forKey: "bitchat.messageRetentionKey") // Clear verified fingerprints verifiedFingerprints.removeAll() // Verified fingerprints are cleared when identity data is cleared below // Reset nickname to anonymous nickname = "anon\(Int.random(in: 1000...9999))" saveNickname() // Clear favorites and peer mappings // Clear through SecureIdentityStateManager instead of directly identityManager.clearAllIdentityData() peerIDToPublicKeyFingerprint.removeAll() // Clear persistent favorites from keychain FavoritesPersistenceService.shared.clearAllFavorites() // Identity manager has cleared persisted identity data above // Clear autocomplete state autocompleteSuggestions.removeAll() showAutocomplete = false autocompleteRange = nil selectedAutocompleteIndex = 0 // Clear selected private chat selectedPrivateChatPeer = nil selectedPrivateChatFingerprint = nil // Clear read receipt tracking sentReadReceipts.removeAll() deduplicationService.clearAll() // Clear all caches invalidateEncryptionCache() // IMPORTANT: Clear Nostr-related state // Disconnect from Nostr relays and clear subscriptions nostrRelayManager?.disconnect() nostrRelayManager = nil // Clear Nostr identity associations idBridge.clearAllAssociations() // Disconnect from all peers and clear persistent identity // This will force creation of a new identity (new fingerprint) on next launch meshService.emergencyDisconnectAll() if let bleService = meshService as? BLEService { bleService.resetIdentityForPanic(currentNickname: nickname) } // No need to force UserDefaults synchronization // 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: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds // Reinitialize Nostr relay manager with new identity nostrRelayManager = NostrRelayManager() setupNostrMessageHandling() nostrRelayManager?.connect() } // Delete ALL media files (incoming and outgoing) in background Task.detached(priority: .utility) { do { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesDir = base.appendingPathComponent("files", isDirectory: true) // Delete the entire files directory and recreate it if FileManager.default.fileExists(atPath: filesDir.path) { try FileManager.default.removeItem(at: filesDir) SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session) } // Recreate empty directory structure try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil) } catch { SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session) } // BCH-01-013: Clear iOS app switcher snapshots // These are stored in Library/Caches/Snapshots// #if os(iOS) Self.clearAppSwitcherSnapshots() #endif } // Force immediate UI update for panic mode // UI updates immediately - no flushing needed } /// BCH-01-013: Clear iOS app switcher snapshots during panic mode /// iOS stores preview screenshots in Library/Caches/Snapshots// /// These could reveal sensitive information visible in the app at the time #if os(iOS) private nonisolated static func clearAppSwitcherSnapshots() { do { let cacheDir = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false) let snapshotsDir = cacheDir.appendingPathComponent("Snapshots", isDirectory: true) // Clear all snapshots (iOS stores them in subdirectories by bundle ID and scene) if FileManager.default.fileExists(atPath: snapshotsDir.path) { let contents = try FileManager.default.contentsOfDirectory(at: snapshotsDir, includingPropertiesForKeys: nil) for item in contents { try FileManager.default.removeItem(at: item) } SecureLogger.info("🗑️ Cleared app switcher snapshots during panic clear", category: .session) } } catch { SecureLogger.error("Failed to clear app switcher snapshots: \(error)", category: .session) } } #endif // MARK: - Autocomplete func updateAutocomplete(for text: String, cursorPosition: Int) { // Build candidate list based on active channel let peerCandidates: [String] = { switch activeChannel { case .mesh: let values = meshService.getPeerNicknames().values return Array(values.filter { $0 != meshService.myNickname }) case .location(let ch): // From geochash participants we have seen via Nostr events var tokens = Set() for (pubkey, nick) in geoNicknames { let suffix = String(pubkey.suffix(4)) tokens.insert("\(nick)#\(suffix)") } // Optionally exclude self nick#abcd from suggestions if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { let myToken = nickname + "#" + String(id.publicKeyHex.suffix(4)) tokens.remove(myToken) } return Array(tokens) } }() let (suggestions, range) = autocompleteService.getSuggestions( for: text, peers: peerCandidates, cursorPosition: cursorPosition ) if !suggestions.isEmpty { autocompleteSuggestions = suggestions autocompleteRange = range showAutocomplete = true selectedAutocompleteIndex = 0 } else { autocompleteSuggestions = [] autocompleteRange = nil showAutocomplete = false selectedAutocompleteIndex = 0 } } func completeNickname(_ nickname: String, in text: inout String) -> Int { guard let range = autocompleteRange else { return text.count } text = autocompleteService.applySuggestion(nickname, to: text, range: range) // Hide autocomplete showAutocomplete = false autocompleteSuggestions = [] autocompleteRange = nil selectedAutocompleteIndex = 0 // Return new cursor position return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2) } // MARK: - Message Formatting @MainActor func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { // Determine if this message was sent by self (mesh, geo, or DM) let isSelf: Bool = { if let spid = message.senderPeerID { // In geohash channels, compare against our per-geohash nostr short ID if case .location(let ch) = activeChannel, spid.isGeoChat { let myGeo: NostrIdentity? = { if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash { return cached.identity } // Fallback: derive and cache (should rarely happen) if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { cachedGeohashIdentity = (ch.geohash, identity) return identity } return nil }() if let myGeo { return spid == PeerID(nostr: myGeo.publicKeyHex) } } return spid == meshService.myPeerID } // Fallback by nickname if message.sender == nickname { return true } if message.sender.hasPrefix(nickname + "#") { return true } return false }() // Check cache first (key includes dark mode + self flag) let isDark = colorScheme == .dark if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) { return cachedText } // Not cached, format the message var result = AttributedString() let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) if message.sender != "system" { // Sender (at the beginning) with light-gray suffix styling if present let (baseName, suffix) = message.sender.splitSuffix() var senderStyle = AttributeContainer() // Use consistent color for all senders senderStyle.foregroundColor = baseColor // Bold the user's own nickname let fontWeight: Font.Weight = isSelf ? .bold : .medium senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced) // Make sender clickable: encode senderPeerID into a custom URL if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") { senderStyle.link = url } // Prefix "<@" result.append(AttributedString("<@").mergingAttributes(senderStyle)) // Base name result.append(AttributedString(baseName).mergingAttributes(senderStyle)) // Optional suffix in lighter variant of the base color (green or orange for self) if !suffix.isEmpty { var suffixStyle = senderStyle suffixStyle.foregroundColor = baseColor.opacity(0.6) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } // Suffix "> " result.append(AttributedString("> ").mergingAttributes(senderStyle)) // Process content with hashtags and mentions let content = message.content // For extremely long content, render as plain text to avoid heavy regex/layout work, // unless the content includes Cashu tokens we want to chip-render below // Compute NSString-backed length for regex/nsrange correctness with multi-byte characters let nsContent = content as NSString let nsLen = nsContent.length let containsCashuEarly: Bool = { let rx = Patterns.quickCashuPresence return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0 }() if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly { var plainStyle = AttributeContainer() plainStyle.foregroundColor = baseColor plainStyle.font = isSelf ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) : .bitchatSystem(size: 14, design: .monospaced) result.append(AttributedString(content).mergingAttributes(plainStyle)) } else { // Reuse compiled regexes and detector from MessageFormattingEngine let hashtagRegex = Patterns.hashtag let mentionRegex = Patterns.mention let cashuRegex = Patterns.cashu let bolt11Regex = Patterns.bolt11 let lnurlRegex = Patterns.lnurl let lightningSchemeRegex = Patterns.lightningScheme let detector = Patterns.linkDetector let hasMentionsHint = content.contains("@") let hasHashtagsHint = content.contains("#") let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http") let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:") let hasCashuHint = content.lowercased().contains("cashu") let hashtagMatches = hasHashtagsHint ? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] let mentionMatches = hasMentionsHint ? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] let urlMatches = hasURLHint ? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : [] let cashuMatches = hasCashuHint ? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] let lightningMatches = hasLightningHint ? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] let bolt11Matches = hasLightningHint ? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] let lnurlMatches = hasLightningHint ? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] // Combine and sort matches, excluding hashtags/URLs overlapping mentions let mentionRanges = mentionMatches.map { $0.range(at: 0) } func overlapsMention(_ r: NSRange) -> Bool { for mr in mentionRanges { if NSIntersectionRange(r, mr).length > 0 { return true } } return false } // Helper: check if a hashtag is immediately attached to a preceding @mention (e.g., @name#abcd) func attachedToMention(_ r: NSRange) -> Bool { if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex { var i = content.index(before: nsRange.lowerBound) while true { let ch = content[i] if ch.isWhitespace || ch.isNewline { break } if ch == "@" { return true } if i == content.startIndex { break } i = content.index(before: i) } } return false } // Helper: ensure '#' starts a new token (start-of-line or whitespace before '#') func isStandaloneHashtag(_ r: NSRange) -> Bool { guard let nsRange = Range(r, in: content) else { return false } if nsRange.lowerBound == content.startIndex { return true } let prev = content.index(before: nsRange.lowerBound) return content[prev].isWhitespace || content[prev].isNewline } var allMatches: [(range: NSRange, type: String)] = [] for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) { allMatches.append((match.range(at: 0), "hashtag")) } for match in mentionMatches { allMatches.append((match.range(at: 0), "mention")) } for match in urlMatches where !overlapsMention(match.range) { allMatches.append((match.range, "url")) } for match in cashuMatches where !overlapsMention(match.range(at: 0)) { allMatches.append((match.range(at: 0), "cashu")) } // Lightning scheme first to avoid overlapping submatches for match in lightningMatches where !overlapsMention(match.range(at: 0)) { allMatches.append((match.range(at: 0), "lightning")) } // Exclude overlaps with lightning/url for bolt11/lnurl let occupied: [NSRange] = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) } func overlapsOccupied(_ r: NSRange) -> Bool { for or in occupied { if NSIntersectionRange(r, or).length > 0 { return true } } return false } for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { allMatches.append((match.range(at: 0), "bolt11")) } for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { allMatches.append((match.range(at: 0), "lnurl")) } allMatches.sort { $0.range.location < $1.range.location } // Build content with styling var lastEnd = content.startIndex let isMentioned = message.mentions?.contains(nickname) ?? false for (range, type) in allMatches { // Add text before match if let nsRange = Range(range, in: content) { if lastEnd < nsRange.lowerBound { let beforeText = String(content[lastEnd..) let token = String(matchText.dropFirst()).lowercased() let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) } // Do not link if this hashtag is directly attached to an @mention (e.g., @name#geohash) let attachedToMention: Bool = { // nsRange is the Range for this match within content // Walk left until whitespace/newline; if we encounter '@' first, treat as part of mention if nsRange.lowerBound > content.startIndex { var i = content.index(before: nsRange.lowerBound) while true { let ch = content[i] if ch.isWhitespace || ch.isNewline { break } if ch == "@" { return true } if i == content.startIndex { break } i = content.index(before: i) } } return false }() // Also require the '#' to start a new token (whitespace or start-of-line before '#') let standalone: Bool = { if nsRange.lowerBound == content.startIndex { return true } let prev = content.index(before: nsRange.lowerBound) return content[prev].isWhitespace || content[prev].isNewline }() var tagStyle = AttributeContainer() tagStyle.font = isSelf ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) : .bitchatSystem(size: 14, design: .monospaced) tagStyle.foregroundColor = baseColor if isGeohash && !attachedToMention && standalone, let url = URL(string: "bitchat://geohash/\(token)") { tagStyle.link = url tagStyle.underlineStyle = .single } result.append(AttributedString(matchText).mergingAttributes(tagStyle)) } else if type == "cashu" { // Skip inline token; a styled chip is rendered below the message // We insert a single space to avoid words sticking together var spacer = AttributeContainer() spacer.foregroundColor = baseColor spacer.font = isSelf ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) : .bitchatSystem(size: 14, design: .monospaced) result.append(AttributedString(" ").mergingAttributes(spacer)) } else if type == "lightning" || type == "bolt11" || type == "lnurl" { // Skip inline invoice/link; a styled chip is rendered below the message var spacer = AttributeContainer() spacer.foregroundColor = baseColor spacer.font = isSelf ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) : .bitchatSystem(size: 14, design: .monospaced) result.append(AttributedString(" ").mergingAttributes(spacer)) } else { // Keep URL styling and make it tappable via .link attribute var matchStyle = AttributeContainer() matchStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced) if type == "url" { matchStyle.foregroundColor = isSelf ? .orange : .blue matchStyle.underlineStyle = .single if let url = URL(string: matchText) { matchStyle.link = url } } result.append(AttributedString(matchText).mergingAttributes(matchStyle)) } } // Advance lastEnd safely in case of overlaps if lastEnd < nsRange.upperBound { lastEnd = nsRange.upperBound } } } // Add remaining text if lastEnd < content.endIndex { let remainingText = String(content[lastEnd...]) var remainingStyle = AttributeContainer() remainingStyle.foregroundColor = baseColor remainingStyle.font = isSelf ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) : .bitchatSystem(size: 14, design: .monospaced) if isMentioned { remainingStyle.font = remainingStyle.font?.bold() } result.append(AttributedString(remainingText).mergingAttributes(remainingStyle)) } } // Add timestamp at the end (smaller, light grey) let timestamp = AttributedString(" [\(message.formattedTimestamp)]") var timestampStyle = AttributeContainer() timestampStyle.foregroundColor = Color.gray.opacity(0.7) timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced) result.append(timestamp.mergingAttributes(timestampStyle)) } else { // System message var contentStyle = AttributeContainer() contentStyle.foregroundColor = Color.gray let content = AttributedString("* \(message.content) *") contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic() result.append(content.mergingAttributes(contentStyle)) // Add timestamp at the end for system messages too let timestamp = AttributedString(" [\(message.formattedTimestamp)]") var timestampStyle = AttributeContainer() timestampStyle.foregroundColor = Color.gray.opacity(0.5) timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced) result.append(timestamp.mergingAttributes(timestampStyle)) } // Cache the formatted text message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf) return result } @MainActor func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { let isSelf: Bool = { if let spid = message.senderPeerID { if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") { if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { return spid == PeerID(nostr: myGeo.publicKeyHex) } } return spid == meshService.myPeerID } if message.sender == nickname { return true } if message.sender.hasPrefix(nickname + "#") { return true } return false }() let isDark = colorScheme == .dark let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) if message.sender == "system" { var style = AttributeContainer() style.foregroundColor = baseColor style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) return AttributedString(message.sender).mergingAttributes(style) } var result = AttributedString() let (baseName, suffix) = message.sender.splitSuffix() var senderStyle = AttributeContainer() senderStyle.foregroundColor = baseColor senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced) if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") { senderStyle.link = url } result.append(AttributedString("<@").mergingAttributes(senderStyle)) result.append(AttributedString(baseName).mergingAttributes(senderStyle)) if !suffix.isEmpty { var suffixStyle = senderStyle suffixStyle.foregroundColor = baseColor.opacity(0.6) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } result.append(AttributedString("> ").mergingAttributes(senderStyle)) return result } // MARK: - Noise Protocol Support @MainActor func updateEncryptionStatusForPeers() { for peerID in connectedPeers { updateEncryptionStatusForPeer(peerID) } } @MainActor private func updateEncryptionStatusForPeer(_ peerID: PeerID) { let noiseService = meshService.getNoiseService() if noiseService.hasEstablishedSession(with: peerID) { peerEncryptionStatus[peerID] = encryptionStatus(for: peerID) } else if noiseService.hasSession(with: peerID) { // Session exists but not established - handshaking peerEncryptionStatus[peerID] = .noiseHandshaking } else { // No session at all peerEncryptionStatus[peerID] = Optional.none } // Invalidate cache when encryption status changes invalidateEncryptionCache(for: peerID) // UI will update automatically via @Published properties } @MainActor func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { // Check cache first if let cachedStatus = encryptionStatusCache[peerID] { return cachedStatus } // This must be a pure function - no state mutations allowed // to avoid SwiftUI update loops // Check if we've ever established a session by looking for a fingerprint let hasEverEstablishedSession = getFingerprint(for: peerID) != nil let sessionState = meshService.getNoiseSessionState(for: peerID) let status: EncryptionStatus // Determine status based on session state switch sessionState { case .established: status = encryptionStatus(for: peerID) case .handshaking, .handshakeQueued: // If we've ever established a session, show secured instead of handshaking if hasEverEstablishedSession { // Check if it was verified before status = encryptionStatus(for: peerID) } else { // First time establishing - show handshaking status = .noiseHandshaking } case .none: // If we've ever established a session, show secured instead of no handshake if hasEverEstablishedSession { // Check if it was verified before status = encryptionStatus(for: peerID) } else { // Never established - show no handshake status = .noHandshake } case .failed: // If we've ever established a session, show secured instead of failed if hasEverEstablishedSession { // Check if it was verified before status = encryptionStatus(for: peerID) } else { // Never established - show failed status = .none } } // Cache the result encryptionStatusCache[peerID] = status // Encryption status determined: \(status) return status } // Clear caches when data changes private func invalidateEncryptionCache(for peerID: PeerID? = nil) { if let peerID { encryptionStatusCache.removeValue(forKey: peerID) } else { encryptionStatusCache.removeAll() } } // MARK: - Message Handling func trimMessagesIfNeeded() { if messages.count > maxMessages { messages = Array(messages.suffix(maxMessages)) } } @MainActor func refreshVisibleMessages(from channel: ChannelID? = nil) { let target = channel ?? activeChannel messages = timelineStore.messages(for: target) } @MainActor private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { if let spid = message.senderPeerID { if spid.isGeoChat || spid.isGeoDM { let full = nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased() return getNostrPaletteColor(for: full, isDark: isDark) } else if spid.id.count == 16 { // Mesh short ID return getPeerPaletteColor(for: spid, isDark: isDark) } else { return getPeerPaletteColor(for: PeerID(str: spid.id.lowercased()), isDark: isDark) } } // Fallback when we only have a display name return Color(peerSeed: message.sender.lowercased(), isDark: isDark) } // MARK: - MessageFormattingContext Protocol @MainActor func isSelfMessage(_ message: BitchatMessage) -> Bool { if let spid = message.senderPeerID { // In geohash channels, compare against our per-geohash nostr short ID if case .location(let ch) = activeChannel, spid.isGeoChat { let myGeo: NostrIdentity? = { if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash { return cached.identity } // Derive and cache if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { cachedGeohashIdentity = (ch.geohash, identity) return identity } return nil }() if let myGeo { return spid == PeerID(nostr: myGeo.publicKeyHex) } } return spid == meshService.myPeerID } // Fallback by nickname if message.sender == nickname { return true } if message.sender.hasPrefix(nickname + "#") { return true } return false } @MainActor func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { return peerColor(for: message, isDark: isDark) } @MainActor func peerURL(for peerID: PeerID) -> URL? { return URL(string: "bitchat://user/\(peerID.toPercentEncoded())") } // Public helpers for views to color peers consistently in lists @MainActor func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color { return getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark) } @MainActor func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color { return getPeerPaletteColor(for: peerID, isDark: isDark) } // MARK: - Peer Palette Coordination private let meshPalette = MinimalDistancePalette(config: .mesh) private let nostrPalette = MinimalDistancePalette(config: .nostr) @MainActor private func meshSeed(for peerID: PeerID) -> String { if let full = getNoiseKeyForShortID(peerID)?.id.lowercased() { return "noise:" + full } return peerID.id.lowercased() } @MainActor private func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color { if peerID == meshService.myPeerID { return .orange } meshPalette.ensurePalette(for: currentMeshPaletteSeeds()) if let color = meshPalette.color(for: peerID.id, isDark: isDark) { return color } return Color(peerSeed: meshSeed(for: peerID), isDark: isDark) } @MainActor private func currentMeshPaletteSeeds() -> [String: String] { let myID = meshService.myPeerID var seeds: [String: String] = [:] for peer in allPeers where peer.peerID != myID { seeds[peer.peerID.id] = meshSeed(for: peer.peerID) } return seeds } @MainActor private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color { let myHex = currentGeohashIdentityHex() if let myHex, pubkeyHexLowercased == myHex { return .orange } nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex)) if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) { return color } return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark) } @MainActor private func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] { var seeds: [String: String] = [:] let excluded = myHex ?? "" for person in visibleGeohashPeople() where person.id != excluded { seeds[person.id] = "nostr:" + person.id } return seeds } @MainActor private func currentGeohashIdentityHex() -> String? { if case .location(let channel) = LocationChannelManager.shared.selectedChannel, let identity = try? idBridge.deriveIdentity(forGeohash: channel.geohash) { return identity.publicKeyHex.lowercased() } return nil } // Clear the current public channel's timeline (visible + persistent buffer) @MainActor func clearCurrentPublicTimeline() { // Clear messages from current timeline messages.removeAll() timelineStore.clear(channel: activeChannel) // Delete associated media files (images, voice notes, files) in background // Only delete from current chat to avoid removing private chat media Task.detached(priority: .utility) { do { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesDir = base.appendingPathComponent("files", isDirectory: true) // Only clear public media (mesh channel only - geohash media is separate) // Note: This is conservative - only clears outgoing since we authored those let outgoingDirs = [ filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), filesDir.appendingPathComponent("images/outgoing", isDirectory: true), filesDir.appendingPathComponent("files/outgoing", isDirectory: true) ] for dir in outgoingDirs { if FileManager.default.fileExists(atPath: dir.path) { try? FileManager.default.removeItem(at: dir) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) } } } catch { SecureLogger.error("Failed to clear media files: \(error)", category: .session) } } } // MARK: - Message Management private func addMessage(_ message: BitchatMessage) { // Check for duplicates guard !messages.contains(where: { $0.id == message.id }) else { return } messages.append(message) trimMessagesIfNeeded() } // Update encryption status in appropriate places, not during view updates @MainActor private func updateEncryptionStatus(for peerID: PeerID) { let noiseService = meshService.getNoiseService() if noiseService.hasEstablishedSession(with: peerID) { peerEncryptionStatus[peerID] = encryptionStatus(for: peerID) } else if noiseService.hasSession(with: peerID) { peerEncryptionStatus[peerID] = .noiseHandshaking } else { peerEncryptionStatus[peerID] = Optional.none } // Invalidate cache when encryption status changes invalidateEncryptionCache(for: peerID) // UI will update automatically via @Published properties } // MARK: - Fingerprint Management func showFingerprint(for peerID: PeerID) { showingFingerprintFor = peerID } // MARK: - Peer Lookup Helpers func getPeer(byID peerID: PeerID) -> BitchatPeer? { return peerIndex[peerID] } @MainActor func getFingerprint(for peerID: PeerID) -> String? { return unifiedPeerService.getFingerprint(for: peerID) } /// Check if fingerprint is verified using our persisted data @MainActor private func encryptionStatus(for peerID: PeerID) -> EncryptionStatus { if let fp = getFingerprint(for: peerID), verifiedFingerprints.contains(fp) { return .noiseVerified } else { return .noiseSecured } } /// Helper to resolve nickname for a peer ID through various sources @MainActor private func resolveNickname(for peerID: PeerID) -> String { // Guard against empty or very short peer IDs guard !peerID.isEmpty else { return "unknown" } // Check if this might already be a nickname (not a hex peer ID) // Peer IDs are hex strings, so they only contain 0-9 and a-f if !peerID.isHex { // If it's already a nickname, just return it return peerID.id } // First try direct peer nicknames from mesh service let peerNicknames = meshService.getPeerNicknames() if let nickname = peerNicknames[peerID] { return nickname } // Try to resolve through fingerprint and social identity if let fingerprint = getFingerprint(for: peerID) { if let identity = identityManager.getSocialIdentity(for: fingerprint) { // Prefer local petname if set if let petname = identity.localPetname { return petname } // Otherwise use their claimed nickname return identity.claimedNickname } } // Use anonymous with shortened peer ID // Ensure we have at least 4 characters for the prefix let prefixLength = min(4, peerID.id.count) let prefix = String(peerID.id.prefix(prefixLength)) // Avoid "anonanon" by checking if ID already starts with "anon" if prefix.starts(with: "anon") { return "peer\(prefix)" } return "anon\(prefix)" } func getMyFingerprint() -> String { let fingerprint = meshService.getNoiseService().getIdentityFingerprint() return fingerprint } @MainActor func verifyFingerprint(for peerID: PeerID) { guard let fingerprint = getFingerprint(for: peerID) else { return } // Update secure storage with verified status identityManager.setVerified(fingerprint: fingerprint, verified: true) saveIdentityState() // Update local set for UI verifiedFingerprints.insert(fingerprint) // Update encryption status after verification updateEncryptionStatus(for: peerID) } @MainActor func unverifyFingerprint(for peerID: PeerID) { guard let fingerprint = getFingerprint(for: peerID) else { return } identityManager.setVerified(fingerprint: fingerprint, verified: false) saveIdentityState() verifiedFingerprints.remove(fingerprint) updateEncryptionStatus(for: peerID) } @MainActor func loadVerifiedFingerprints() { // Load verified fingerprints directly from secure storage verifiedFingerprints = identityManager.getVerifiedFingerprints() // Log snapshot for debugging persistence let sample = Array(verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: ", ") SecureLogger.info("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: .security) // Also log any offline favorites and whether we consider them verified let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected } for fav in offlineFavorites { let fp = unifiedPeerService.getFingerprint(for: fav.peerID) let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false let fpShort = fp?.prefix(8) ?? "nil" SecureLogger.info("⭐️ Favorite offline: \(fav.nickname) fp=\(fpShort) verified=\(isVer)", category: .security) } // Invalidate cached encryption statuses so offline favorites can show verified badges immediately invalidateEncryptionCache() // Trigger UI refresh of peer list objectWillChange.send() } private func setupNoiseCallbacks() { let noiseService = meshService.getNoiseService() // Set up authentication callback noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in DispatchQueue.main.async { guard let self = self else { return } SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security) // Update encryption status if self.verifiedFingerprints.contains(fingerprint) { self.peerEncryptionStatus[peerID] = .noiseVerified // Encryption: noiseVerified } else { self.peerEncryptionStatus[peerID] = .noiseSecured // Encryption: noiseSecured } // Invalidate cache when encryption status changes self.invalidateEncryptionCache(for: peerID) // Cache shortID -> full Noise key mapping as soon as session authenticates if self.shortIDToNoiseKey[peerID] == nil, let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) { let stable = PeerID(hexData: keyData) self.shortIDToNoiseKey[peerID] = stable SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.id.prefix(8))…", category: .session) } // If a QR verification is pending but not sent yet, send it now that session is authenticated if var pending = self.pendingQRVerifications[peerID], pending.sent == false { self.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: pending.noiseKeyHex, nonceA: pending.nonceA) pending.sent = true self.pendingQRVerifications[peerID] = pending SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security) } // Schedule UI update // UI will update automatically } } // Set up handshake required callback noiseService.onHandshakeRequired = { [weak self] peerID in DispatchQueue.main.async { guard let self = self else { return } self.peerEncryptionStatus[peerID] = .noiseHandshaking // Invalidate cache when encryption status changes self.invalidateEncryptionCache(for: peerID) } } } // MARK: - BitchatDelegate Methods // MARK: - Command Handling /// Processes IRC-style commands starting with '/'. /// - Parameter command: The full command string including the leading slash /// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help @MainActor private func handleCommand(_ command: String) { let result = commandProcessor.process(command) switch result { case .success(let message): if let msg = message { addSystemMessage(msg) } case .error(let message): addSystemMessage(message) case .handled: // Command was handled, no message needed break } } // MARK: - Message Reception func didReceiveMessage(_ message: BitchatMessage) { Task { @MainActor in // Early validation guard !isMessageBlocked(message) else { return } guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return } // Route to appropriate handler if message.isPrivate { handlePrivateMessage(message) } else { handlePublicMessage(message) } // Post-processing checkForMentions(message) sendHapticFeedback(for: message) } } /// Find message index trying both short (16-hex) and long (64-hex) peer ID formats. /// Returns the peer ID where the message was found and its index, or nil if not found. private func findMessageIndex(messageID: String, peerID: PeerID) -> (peerID: PeerID, index: Int)? { // Try direct lookup first if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) { return (peerID, idx) } // Try with full noise key if peerID is short (16 hex chars) if peerID.bare.count == 16, let peer = unifiedPeerService.getPeer(by: peerID), !peer.noisePublicKey.isEmpty { let longID = PeerID(hexData: peer.noisePublicKey) if let messages = privateChats[longID], let idx = messages.firstIndex(where: { $0.id == messageID }) { return (longID, idx) } } // Try with short form if peerID is long (64 hex = noise key) if peerID.bare.count == 64 { let shortID = peerID.toShort() if let messages = privateChats[shortID], let idx = messages.firstIndex(where: { $0.id == messageID }) { return (shortID, idx) } } return nil } // Low-level BLE events func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { Task { @MainActor in switch type { case .privateMessage: guard let pm = PrivateMessagePacket.decode(from: payload) else { return } // BCH-01-012: Check blocking before processing private message to prevent notification bypass if isPeerBlocked(peerID) { SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security) return } let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown" let pmMentions = parseMentions(from: pm.content) let msg = BitchatMessage( id: pm.messageID, sender: senderName, content: pm.content, timestamp: timestamp, isRelay: false, originalSender: nil, isPrivate: true, recipientNickname: nickname, senderPeerID: peerID, mentions: pmMentions.isEmpty ? nil : pmMentions ) handlePrivateMessage(msg) // Send delivery ACK back over BLE meshService.sendDeliveryAck(for: pm.messageID, to: peerID) case .delivered: guard let messageID = String(data: payload, encoding: .utf8) else { return } guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname, let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return } // Don't downgrade from .read to .delivered if case .read = privateChats[foundPeerID]?[idx].deliveryStatus { return } privateChats[foundPeerID]?[idx].deliveryStatus = .delivered(to: name, at: Date()) objectWillChange.send() case .readReceipt: guard let messageID = String(data: payload, encoding: .utf8) else { return } guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname, let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return } // Explicitly unwrap and re-assign to ensure the @Published setter is called if let messages = privateChats[foundPeerID], idx < messages.count { messages[idx].deliveryStatus = .read(by: name, at: Date()) privateChats[foundPeerID] = messages privateChatManager.objectWillChange.send() objectWillChange.send() } case .verifyChallenge: // Parse and respond guard let tlv = VerificationService.shared.parseVerifyChallenge(payload) else { return } // Ensure intended for our noise key let myNoiseHex = meshService.getNoiseService().getStaticPublicKeyData().hexEncodedString().lowercased() guard tlv.noiseKeyHex.lowercased() == myNoiseHex else { return } // Deduplicate: ignore if we've already responded to this nonce for this peer if let last = lastVerifyNonceByPeer[peerID], last == tlv.nonceA { return } lastVerifyNonceByPeer[peerID] = tlv.nonceA // Record inbound challenge time keyed by stable fingerprint if available if let fp = getFingerprint(for: peerID) { lastInboundVerifyChallengeAt[fp] = Date() // If we've already verified this fingerprint locally, treat this as mutual and toast immediately (responder side) if verifiedFingerprints.contains(fp) { let now = Date() let last = lastMutualToastAt[fp] ?? .distantPast if now.timeIntervalSince(last) > 60 { // 1-minute throttle lastMutualToastAt[fp] = now let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID) NotificationService.shared.sendLocalNotification( title: "Mutual verification", body: "You and \(name) verified each other", identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)" ) } } } meshService.sendVerifyResponse(to: peerID, noiseKeyHex: tlv.noiseKeyHex, nonceA: tlv.nonceA) // Silent response: no toast needed on responder case .verifyResponse: guard let resp = VerificationService.shared.parseVerifyResponse(payload) else { return } // Check pending for this peer guard let pending = pendingQRVerifications[peerID] else { return } guard resp.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), resp.nonceA == pending.nonceA else { return } // Verify signature with expected sign key let ok = VerificationService.shared.verifyResponseSignature(noiseKeyHex: resp.noiseKeyHex, nonceA: resp.nonceA, signature: resp.signature, signerPublicKeyHex: pending.signKeyHex) if ok { pendingQRVerifications.removeValue(forKey: peerID) if let fp = getFingerprint(for: peerID) { let short = fp.prefix(8) SecureLogger.info("🔐 Marking verified fingerprint: \(short)", category: .security) identityManager.setVerified(fingerprint: fp, verified: true) saveIdentityState() verifiedFingerprints.insert(fp) let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID) NotificationService.shared.sendLocalNotification( title: "Verified", body: "You verified \(name)", identifier: "verify-success-\(peerID)-\(UUID().uuidString)" ) // If we also recently responded to their challenge, flag mutual and toast (initiator side) if let t = lastInboundVerifyChallengeAt[fp], Date().timeIntervalSince(t) < 600 { let now = Date() let lastToast = lastMutualToastAt[fp] ?? .distantPast if now.timeIntervalSince(lastToast) > 60 { lastMutualToastAt[fp] = now NotificationService.shared.sendLocalNotification( title: "Mutual verification", body: "You and \(name) verified each other", identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)" ) } } updateEncryptionStatus(for: peerID) } } } } } func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { Task { @MainActor in let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines) let publicMentions = parseMentions(from: normalized) let msg = BitchatMessage( id: messageID, sender: nickname, content: normalized, timestamp: timestamp, isRelay: false, originalSender: nil, isPrivate: false, recipientNickname: nil, senderPeerID: peerID, mentions: publicMentions.isEmpty ? nil : publicMentions ) handlePublicMessage(msg) checkForMentions(msg) sendHapticFeedback(for: msg) } } // MARK: - QR Verification API @MainActor func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { // Find a matching peer by Noise key let targetNoise = qr.noiseKeyHex.lowercased() guard let peer = unifiedPeerService.peers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else { return false } let peerID = peer.peerID // If we already have a pending verification with this peer, don't send another if pendingQRVerifications[peerID] != nil { return true } // Generate nonceA var nonce = Data(count: 16) _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } var pending = PendingVerification(noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, nonceA: nonce, startedAt: Date(), sent: false) pendingQRVerifications[peerID] = pending // If Noise session is established, send immediately; otherwise trigger handshake and send on auth let noise = meshService.getNoiseService() if noise.hasEstablishedSession(with: peerID) { meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) pending.sent = true pendingQRVerifications[peerID] = pending } else { meshService.triggerHandshake(with: peerID) } return true } // Mention parsing moved from BLE – use the existing non-optional helper below // MARK: - Bluetooth State Monitoring func didUpdateBluetoothState(_ state: CBManagerState) { Task { @MainActor in updateBluetoothState(state) } } // MARK: - Peer Connection Events func didConnectToPeer(_ peerID: PeerID) { SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session) // Handle all main actor work async Task { @MainActor in isConnected = true // Register ephemeral session with identity manager identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) // Intentionally do not resend favorites on reconnect. // We only send our npub when a favorite is toggled on, or if our npub changes. // Force UI refresh objectWillChange.send() // Cache mapping to full Noise key for session continuity on disconnect if let peer = unifiedPeerService.getPeer(by: peerID) { let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) shortIDToNoiseKey[peerID] = noiseKeyHex } // Flush any queued messages for this peer via router messageRouter.flushOutbox(for: peerID) } } func didDisconnectFromPeer(_ peerID: PeerID) { SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session) // Remove ephemeral session from identity manager identityManager.removeEphemeralSession(peerID: peerID) // If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite) var derivedStableKeyHex = shortIDToNoiseKey[peerID] if derivedStableKeyHex == nil, let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) { derivedStableKeyHex = PeerID(hexData: key) shortIDToNoiseKey[peerID] = derivedStableKeyHex } if let current = selectedPrivateChatPeer, current == peerID, let stableKeyHex = derivedStableKeyHex { // Migrate messages view context to stable key so header shows favorite + Nostr globe if let messages = privateChats[peerID] { if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] } let existing = Set(privateChats[stableKeyHex]!.map { $0.id }) for msg in messages where !existing.contains(msg.id) { let updated = BitchatMessage( id: msg.id, sender: msg.sender, content: msg.content, timestamp: msg.timestamp, isRelay: msg.isRelay, originalSender: msg.originalSender, isPrivate: msg.isPrivate, recipientNickname: msg.recipientNickname, senderPeerID: msg.senderPeerID == meshService.myPeerID ? meshService.myPeerID : stableKeyHex, mentions: msg.mentions, deliveryStatus: msg.deliveryStatus ) privateChats[stableKeyHex]?.append(updated) } privateChats[stableKeyHex]?.sort { $0.timestamp < $1.timestamp } privateChats.removeValue(forKey: peerID) } if unreadPrivateMessages.contains(peerID) { unreadPrivateMessages.remove(peerID) unreadPrivateMessages.insert(stableKeyHex) } selectedPrivateChatPeer = stableKeyHex objectWillChange.send() } // 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] { for message in messages { // Remove read receipts for messages FROM this peer (not TO this peer) if message.senderPeerID == peerID { sentReadReceipts.remove(message.id) } } } } func didUpdatePeerList(_ peers: [PeerID]) { // UI updates must run on the main thread. // The delegate callback is not guaranteed to be on the main thread. DispatchQueue.main.async { // Update through peer manager // UnifiedPeerService updates automatically via subscriptions self.isConnected = !peers.isEmpty // Clean up stale unread peer IDs whenever peer list updates self.cleanupStaleUnreadPeerIDs() // Smart notification logic for "bitchatters nearby" let meshPeers = peers.filter { peerID in self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID) } let meshPeerSet = Set(meshPeers) if meshPeerSet.isEmpty { self.scheduleNetworkEmptyTimer() } else { self.invalidateNetworkEmptyTimer() // Don't trim recentlySeenPeers here - let timers handle cleanup. // Trimming immediately causes peers to be treated as "new" when they // briefly drop and reconnect, triggering notification floods. let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers) if !newPeers.isEmpty { // Rate limit: max one notification per 5 minutes let cooldown = TransportConfig.networkNotificationCooldownSeconds if Date().timeIntervalSince(self.lastNetworkNotificationTime) >= cooldown { // Only mark peers as seen when we actually notify about them // This ensures peers arriving during cooldown will be included in the next notification self.recentlySeenPeers.formUnion(newPeers) self.lastNetworkNotificationTime = Date() NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) SecureLogger.info( "👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))", category: .session ) } self.scheduleNetworkResetTimer() } } // Register ephemeral sessions for all connected peers for peerID in peers { self.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) } // Schedule UI refresh to ensure offline favorites are shown // UI will update automatically // Update encryption status for all peers self.updateEncryptionStatusForPeers() // Schedule UI update for peer list change // UI will update automatically // Check if we need to update private chat peer after reconnection if self.selectedPrivateChatFingerprint != nil { self.updatePrivateChatPeerIfNeeded() } // Don't end private chat when peer temporarily disconnects // The fingerprint tracking will allow us to reconnect when they come back } } // 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.peerID }) let staleIDs = unreadPrivateMessages.subtracting(currentPeerIDs) if !staleIDs.isEmpty { var idsToRemove: [PeerID] = [] for staleID in staleIDs { // Don't remove temporary Nostr peer IDs that have messages if staleID.isGeoDM { // 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.isNoiseKeyHex { 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.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session) } } // Also clean up old sentReadReceipts to prevent unlimited growth // Keep only receipts from messages we still have cleanupOldReadReceipts() } @MainActor private func scheduleNetworkResetTimer() { networkResetTimer?.invalidate() networkResetTimer = Timer.scheduledTimer( timeInterval: networkResetGraceSeconds, target: self, selector: #selector(onNetworkResetTimerFired(_:)), userInfo: nil, repeats: false ) } @MainActor @objc private func onNetworkResetTimerFired(_ timer: Timer) { let activeMeshPeers = meshService .currentPeerSnapshots() .filter { snapshot in snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID) } if activeMeshPeers.isEmpty { recentlySeenPeers.removeAll() SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session) } else { SecureLogger.debug("⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers", category: .session) } networkResetTimer = nil } @MainActor private func scheduleNetworkEmptyTimer() { guard networkEmptyTimer == nil else { return } networkEmptyTimer = Timer.scheduledTimer( timeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds, target: self, selector: #selector(onNetworkEmptyTimerFired(_:)), userInfo: nil, repeats: false ) SecureLogger.debug("⏳ Mesh empty — waiting before resetting notification state", category: .session) } @MainActor private func invalidateNetworkEmptyTimer() { if networkEmptyTimer != nil { networkEmptyTimer?.invalidate() networkEmptyTimer = nil } } @MainActor @objc private func onNetworkEmptyTimerFired(_ timer: Timer) { let activeMeshPeers = meshService .currentPeerSnapshots() .filter { snapshot in snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID) } if activeMeshPeers.isEmpty { recentlySeenPeers.removeAll() SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session) } else { SecureLogger.debug("⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again", category: .session) } networkEmptyTimer = nil } 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.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session) } } func parseMentions(from content: String) -> [String] { // Allow optional disambiguation suffix '#abcd' for duplicate nicknames let regex = Patterns.mention let nsContent = content as NSString let nsLen = nsContent.length let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) var mentions: [String] = [] let peerNicknames = meshService.getPeerNicknames() // Compose the valid mention tokens based on current peers (already suffixed where needed) var validTokens = Set(peerNicknames.values) // Always allow mentioning self by base nickname and suffixed disambiguator validTokens.insert(nickname) let selfSuffixToken = nickname + "#" + String(meshService.myPeerID.id.prefix(4)) validTokens.insert(selfSuffixToken) for match in matches { if let range = Range(match.range(at: 1), in: content) { let mentionedName = String(content[range]) // Only include if it's a current valid token (base or suffixed) if validTokens.contains(mentionedName) { mentions.append(mentionedName) } } } return Array(Set(mentions)) // Remove duplicates } func isFavorite(fingerprint: String) -> Bool { return identityManager.isFavorite(fingerprint: fingerprint) } // MARK: - Delivery Tracking func didReceiveReadReceipt(_ receipt: ReadReceipt) { // Find the message and update its read status updateMessageDeliveryStatus(receipt.originalMessageID, status: .read(by: receipt.readerNickname, at: receipt.timestamp)) } func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { updateMessageDeliveryStatus(messageID, status: status) } func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { // Helper function to check if we should skip this update func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool { guard let current = currentStatus else { return false } // Don't downgrade from read to delivered switch (current, newStatus) { case (.read, .delivered): return true case (.read, .sent): return true default: return false } } // Update in main messages if let index = messages.firstIndex(where: { $0.id == messageID }) { let currentStatus = messages[index].deliveryStatus if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) { messages[index].deliveryStatus = status } } // Update in private chats 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 } // Trigger UI update for delivery status change DispatchQueue.main.async { [weak self] in self?.objectWillChange.send() } } // MARK: - Helper for System Messages func addSystemMessage(_ content: String, timestamp: Date = Date()) { let systemMessage = BitchatMessage( sender: "system", content: content, timestamp: timestamp, isRelay: false ) messages.append(systemMessage) } /// Add a system message to the mesh timeline only (never geohash). /// If mesh is currently active, also append to the visible `messages`. @MainActor func addMeshOnlySystemMessage(_ content: String) { let systemMessage = BitchatMessage( sender: "system", content: content, timestamp: Date(), isRelay: false ) timelineStore.append(systemMessage, to: .mesh) refreshVisibleMessages() trimMessagesIfNeeded() objectWillChange.send() } /// Public helper to add a system message to the public chat timeline. /// Also persists the message into the active channel's backing store so it survives timeline rebinds. @MainActor func addPublicSystemMessage(_ content: String) { let systemMessage = BitchatMessage( sender: "system", content: content, timestamp: Date(), isRelay: false ) timelineStore.append(systemMessage, to: activeChannel) refreshVisibleMessages(from: activeChannel) // Track the content key so relayed copies of the same system-style message are ignored let contentKey = deduplicationService.normalizedContentKey(systemMessage.content) deduplicationService.recordContentKey(contentKey, timestamp: systemMessage.timestamp) trimMessagesIfNeeded() objectWillChange.send() } /// Add a system message only if viewing a geohash location channel (never post to mesh). @MainActor func addGeohashOnlySystemMessage(_ content: String) { if case .location = activeChannel { addPublicSystemMessage(content) } else { // Not on a location channel yet: queue to show when user switches timelineStore.queueGeohashSystemMessage(content) } } // Send a public message without adding a local user echo. // Used for emotes where we want a local system-style confirmation instead. @MainActor func sendPublicRaw(_ content: String) { if case .location(let ch) = activeChannel { Task { @MainActor in do { let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) let event = try NostrProtocol.createEphemeralGeohashEvent( content: content, geohash: ch.geohash, senderIdentity: identity, nickname: self.nickname, teleported: LocationChannelManager.shared.teleported ) let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) if targetRelays.isEmpty { NostrRelayManager.shared.sendEvent(event) } else { NostrRelayManager.shared.sendEvent(event, to: targetRelays) } } catch { SecureLogger.error("❌ Failed to send geohash raw message: \(error)", category: .session) } } return } // Default: send over mesh meshService.sendMessage(content, mentions: [], messageID: UUID().uuidString, timestamp: Date()) } // MARK: - Base64URL utils static func base64URLDecode(_ s: String) -> Data? { var str = s.replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") // Add padding if needed let rem = str.count % 4 if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) } return Data(base64Encoded: str) } // /// Handle incoming public message @MainActor func handlePublicMessage(_ message: BitchatMessage) { let finalMessage = processActionMessage(message) // Drop if sender is blocked (covers geohash via Nostr pubkey mapping) if isMessageBlocked(finalMessage) { return } // Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system) let isGeo = finalMessage.senderPeerID?.isGeoChat == true // Apply per-sender and per-content rate limits (drop if exceeded) // Treat action-style system messages (which carry a senderPeerID) the same as regular user messages let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil if shouldRateLimit { let senderKey = normalizedSenderKey(for: finalMessage) let contentKey = deduplicationService.normalizedContentKey(finalMessage.content) if !publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) { return } } // Size cap: drop extremely large public messages early if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return } // Persist mesh messages to mesh timeline always if !isGeo && finalMessage.sender != "system" { timelineStore.append(finalMessage, to: .mesh) } // Persist geochat messages to per-geohash timeline if isGeo && finalMessage.sender != "system" { if let gh = currentGeohash { _ = timelineStore.appendIfAbsent(finalMessage, toGeohash: gh) } } // Only add message to current timeline if it matches active channel or is system let isSystem = finalMessage.sender == "system" let channelMatches: Bool = { switch activeChannel { case .mesh: return !isGeo || isSystem case .location: return isGeo || isSystem } }() guard channelMatches else { return } // Removed background nudge notification for generic "new chats!" // Append via batching buffer (skip empty content) with simple dedup by ID if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if !messages.contains(where: { $0.id == finalMessage.id }) { publicMessagePipeline.enqueue(finalMessage) } } } /// Check for mentions and send notifications func checkForMentions(_ message: BitchatMessage) { // Determine our acceptable mention token. If any connected peer shares our nickname, // require the disambiguated form '#' to trigger. var myTokens: Set = [nickname] let meshPeers = meshService.getPeerNicknames() let collisions = meshPeers.values.filter { $0.hasPrefix(nickname + "#") } if !collisions.isEmpty { let suffix = "#" + String(meshService.myPeerID.id.prefix(4)) myTokens = [nickname + suffix] } let isMentioned = (message.mentions?.contains { myTokens.contains($0) } ?? false) if isMentioned && message.sender != nickname { SecureLogger.info("🔔 Mention from \(message.sender)", category: .session) NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) } } /// Send haptic feedback for special messages (iOS only) func sendHapticFeedback(for message: BitchatMessage) { #if os(iOS) guard UIApplication.shared.applicationState == .active else { return } // Build acceptable target tokens: base nickname and, if in a location channel, nickname with '#abcd' var tokens: [String] = [nickname] #if os(iOS) switch activeChannel { case .location(let ch): if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { let d = String(id.publicKeyHex.suffix(4)) tokens.append(nickname + "#" + d) } case .mesh: break } #endif let hugsMe = tokens.contains { message.content.contains("hugs \($0)") } || message.content.contains("hugs you") let slapsMe = tokens.contains { message.content.contains("slaps \($0) around") } || message.content.contains("slaps you around") let isHugForMe = message.content.contains("🫂") && hugsMe let isSlapForMe = message.content.contains("🐟") && slapsMe 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) * TransportConfig.uiBatchDispatchStaggerSeconds) { 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 extension ChatViewModel: PublicMessagePipelineDelegate { func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] { messages } func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) { self.messages = messages } func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { deduplicationService.normalizedContentKey(content) } func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { deduplicationService.contentTimestamp(forKey: key) } func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { deduplicationService.recordContentKey(key, timestamp: timestamp) } func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) { trimMessagesIfNeeded() } func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) { _ = formatMessageAsText(message, colorScheme: currentColorScheme) } func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) { isBatchingPublic = isBatching } } ================================================ FILE: bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift ================================================ // // ChatViewModel+Nostr.swift // bitchat // // Geohash and Nostr logic for ChatViewModel // import Foundation import Combine import BitLogger import SwiftUI import Tor extension ChatViewModel { // MARK: - Geohash Subscription // Resubscribe to the active geohash channel without clearing timeline @MainActor func resubscribeCurrentGeohash() { guard case .location(let ch) = activeChannel else { return } guard let subID = geoSubscriptionID else { // No existing subscription; set it up switchLocationChannel(to: activeChannel) return } // Ensure participant decay timer is running participantTracker.startRefreshTimer() // Unsubscribe + resubscribe NostrRelayManager.shared.unsubscribe(id: subID) let filter = NostrFilter.geohashEphemeral( ch.geohash, since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), limit: TransportConfig.nostrGeohashInitialLimit ) let subRelays = GeoRelayDirectory.shared.closestRelays( toGeohash: ch.geohash, count: TransportConfig.nostrGeoRelayCount ) NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in self?.subscribeNostrEvent(event) } // Resubscribe geohash DMs for this identity if let dmSub = geoDmSubscriptionID { NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil } if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { let dmSub = "geo-dm-\(ch.geohash)" geoDmSubscriptionID = dmSub let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in self?.subscribeGiftWrap(giftWrap, id: id) } } } func subscribeNostrEvent(_ event: NostrEvent) { guard event.isValidSignature() else { return } guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), !deduplicationService.hasProcessedNostrEvent(event.id) else { return } deduplicationService.recordNostrEvent(event.id) if let gh = currentGeohash, let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh), myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { // Skip very recent self-echo from relay, but allow older events (e.g., after app restart) let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) if Date().timeIntervalSince(eventTime) < 15 { return } } if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines) geoNicknames[event.pubkey.lowercased()] = nick } // Store mapping for geohash sender IDs used in messages (ensures consistent colors) nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey // Update participants last-seen for this pubkey participantTracker.recordParticipant(pubkeyHex: event.pubkey) // If presence heartbeat (Kind 20001), stop here - no content to display if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { return } // Track teleported tag (only our format ["t","teleport"]) for icon state let hasTeleportTag = event.tags.contains(where: { tag in tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" }) if hasTeleportTag { let key = event.pubkey.lowercased() // Do not mark our own key from historical events; rely on manager.teleported for self let isSelf: Bool = { if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) { return my.publicKeyHex.lowercased() == key } return false }() if !isSelf { Task { @MainActor in teleportedGeo = teleportedGeo.union([key]) } } } let senderName = displayNameForNostrPubkey(event.pubkey) let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) // Clamp future timestamps to now to avoid future-dated messages skewing order let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let timestamp = min(rawTs, Date()) let mentions = parseMentions(from: content) let msg = BitchatMessage( id: event.id, sender: senderName, content: content, timestamp: timestamp, isRelay: false, senderPeerID: PeerID(nostr: event.pubkey), mentions: mentions.isEmpty ? nil : mentions ) Task { @MainActor in // BCH-01-012: Check blocking before any notifications // handlePublicMessage has its own blocking check but returns silently, // so we must also guard checkForMentions to prevent notification bypass let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) handlePublicMessage(msg) // Only check mentions and send haptic if sender is not blocked if !isBlocked { checkForMentions(msg) sendHapticFeedback(for: msg) } } } func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { guard giftWrap.isValidSignature() else { return } guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return } deduplicationService.recordNostrEvent(giftWrap.id) guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id), let packet = Self.decodeEmbeddedBitChatPacket(from: content), packet.type == MessageType.noiseEncrypted.rawValue, let noisePayload = NoisePayload.decode(packet.payload) else { return } let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) let convKey = PeerID(nostr_: senderPubkey) nostrKeyMapping[convKey] = senderPubkey switch noisePayload.type { case .privateMessage: handlePrivateMessage(noisePayload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp) case .delivered: handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) case .verifyChallenge, .verifyResponse: // QR verification payloads over Nostr are not supported; ignore in geohash DMs break } } // MARK: - Geohash Channel Handling @MainActor func switchLocationChannel(to channel: ChannelID) { // Reset pending public batches to avoid cross-channel bleed publicMessagePipeline.reset() activeChannel = channel publicMessagePipeline.updateActiveChannel(channel) // Reset deduplication set and optionally hydrate timeline for mesh deduplicationService.clearNostrCaches() switch channel { case .mesh: refreshVisibleMessages(from: .mesh) // Debug: log if any empty messages are present let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count if emptyMesh > 0 { SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) } participantTracker.stopRefreshTimer() participantTracker.setActiveGeohash(nil) teleportedGeo.removeAll() case .location: refreshVisibleMessages(from: channel) } // If switching to a location channel, flush any pending geohash-only system messages if case .location = channel { for content in timelineStore.drainPendingGeohashSystemMessages() { addPublicSystemMessage(content) } } // Unsubscribe previous if let sub = geoSubscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) geoSubscriptionID = nil } if let dmSub = geoDmSubscriptionID { NostrRelayManager.shared.unsubscribe(id: dmSub) geoDmSubscriptionID = nil } currentGeohash = nil participantTracker.setActiveGeohash(nil) // Reset nickname cache for geochat participants geoNicknames.removeAll() guard case .location(let ch) = channel else { return } currentGeohash = ch.geohash participantTracker.setActiveGeohash(ch.geohash) // Ensure self appears immediately in the people list; mark teleported state only when truly teleported if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex) let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } let key = id.publicKeyHex.lowercased() if LocationChannelManager.shared.teleported && hasRegional && !inRegional { teleportedGeo = teleportedGeo.union([key]) SecureLogger.info("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session) } else { teleportedGeo.remove(key) } } let subID = "geo-\(ch.geohash)" geoSubscriptionID = subID participantTracker.startRefreshTimer() let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in self?.handleNostrEvent(event) } subscribeToGeoChat(ch) } func handleNostrEvent(_ event: NostrEvent) { guard event.isValidSignature() else { return } // Only handle ephemeral kind 20000 or presence kind 20001 with matching tag guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } // Deduplicate if deduplicationService.hasProcessedNostrEvent(event.id) { return } deduplicationService.recordNostrEvent(event.id) // Log incoming tags for diagnostics let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session) // If this pubkey is blocked, skip mapping, participants, and timeline if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { return } // Track teleport tag for participants – only our format ["t", "teleport"] let hasTeleportTag: Bool = event.tags.contains { tag in tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" } let isSelf: Bool = { if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) { return my.publicKeyHex.lowercased() == event.pubkey.lowercased() } return false }() if hasTeleportTag { // Avoid marking our own key from historical events; rely on manager.teleported for self if !isSelf { let key = event.pubkey.lowercased() Task { @MainActor in teleportedGeo = teleportedGeo.union([key]) SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session) } } } // Update participants last-seen for this pubkey participantTracker.recordParticipant(pubkeyHex: event.pubkey) // Skip only very recent self-echo from relay; include older self events for hydration if isSelf { let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) if Date().timeIntervalSince(eventTime) < 15 { return } } // Cache nickname from tag if present if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines) geoNicknames[event.pubkey.lowercased()] = nick } // Store mapping for geohash DM initiation nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey // If presence heartbeat (Kind 20001), stop here - no content to display if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { return } let senderName = displayNameForNostrPubkey(event.pubkey) let content = event.content // If this is a teleport presence event (no content), don't add to timeline if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"), content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return } // Clamp future timestamps let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let mentions = parseMentions(from: content) let msg = BitchatMessage( id: event.id, sender: senderName, content: content, timestamp: min(rawTs, Date()), isRelay: false, senderPeerID: PeerID(nostr: event.pubkey), mentions: mentions.isEmpty ? nil : mentions ) Task { @MainActor in handlePublicMessage(msg) checkForMentions(msg) sendHapticFeedback(for: msg) } } @MainActor func subscribeToGeoChat(_ ch: GeohashChannel) { guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return } let dmSub = "geo-dm-\(ch.geohash)" geoDmSubscriptionID = dmSub // pared back logging: subscribe debug only // Log GeoDM subscribe only when Tor is ready to avoid early noise if TorManager.shared.isReady { SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session) } let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in self?.handleGiftWrap(giftWrap, id: id) } } func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { guard giftWrap.isValidSignature() else { return } if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return } deduplicationService.recordNostrEvent(giftWrap.id) // Decrypt with per-geohash identity guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session) return } SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session) guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), packet.type == MessageType.noiseEncrypted.rawValue, let payload = NoisePayload.decode(packet.payload) else { return } let convKey = PeerID(nostr_: senderPubkey) nostrKeyMapping[convKey] = senderPubkey switch payload.type { case .privateMessage: let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp) case .delivered: handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) // Explicitly list other cases so we get compile-time check if a new case is added in the future case .verifyChallenge, .verifyResponse: break } } @MainActor func sendGeohash(context: GeoOutgoingContext) { let ch = context.channel let event = context.event let identity = context.identity let targetRelays = GeoRelayDirectory.shared.closestRelays( toGeohash: ch.geohash, count: TransportConfig.nostrGeoRelayCount ) if targetRelays.isEmpty { SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session) } else { NostrRelayManager.shared.sendEvent(event, to: targetRelays) } // Track ourselves as active participant participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session) // If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI // Only when not in our regional set (and regional list is known) let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } if context.teleported && hasRegional && !inRegional { let key = identity.publicKeyHex.lowercased() teleportedGeo = teleportedGeo.union([key]) SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session) } deduplicationService.recordNostrEvent(event.id) } // MARK: - Sampling /// Begin sampling multiple geohashes (used by channel sheet) without changing active channel. @MainActor func beginGeohashSampling(for geohashes: [String]) { // Disable sampling when app is backgrounded (Tor is stopped there) if !TorManager.shared.isForeground() { endGeohashSampling() return } // Determine which to add and which to remove let desired = Set(geohashes) let current = Set(geoSamplingSubs.values) let toAdd = desired.subtracting(current) let toRemove = current.subtracting(desired) for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) { NostrRelayManager.shared.unsubscribe(id: subID) geoSamplingSubs.removeValue(forKey: subID) } for gh in toAdd { subscribe(gh) } } @MainActor func subscribe(_ gh: String) { let subID = "geo-sample-\(gh)" geoSamplingSubs[subID] = gh let filter = NostrFilter.geohashEphemeral( gh, since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), limit: TransportConfig.nostrGeohashSampleLimit ) let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in self?.subscribeNostrEvent(event, gh: gh) } } func subscribeNostrEvent(_ event: NostrEvent, gh: String) { guard event.isValidSignature() else { return } guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } // Compute current participant count (5-minute window) BEFORE updating with this event let existingCount = participantTracker.participantCount(for: gh) // Update participants for this specific geohash participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh) // Notify only on rising-edge: previously zero people, now someone sends a chat let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty else { return } // Respect geohash blocks if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } // Skip self identity for this geohash if let my = try? idBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } // Only trigger when there were zero participants in this geohash recently guard existingCount == 0 else { return } // Avoid notifications for old sampled events when launching or (re)subscribing let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) if Date().timeIntervalSince(eventTime) > 30 { return } // Foreground-only notifications: app must be active, and not already viewing this geohash #if os(iOS) guard UIApplication.shared.applicationState == .active else { return } if case .location(let ch) = activeChannel, ch.geohash == gh { return } #elseif os(macOS) guard NSApplication.shared.isActive else { return } if case .location(let ch) = activeChannel, ch.geohash == gh { return } #endif cooldownPerGeohash(gh, content: content, event: event) } func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { let now = Date() let last = lastGeoNotificationAt[gh] ?? .distantPast if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } // Compose a short preview let preview: String = { let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen if content.count <= maxLen { return content } let idx = content.index(content.startIndex, offsetBy: maxLen) return String(content[.. Data? { // Check favorites for this Nostr key let favorites = FavoritesPersistenceService.shared.favorites.values var npubToMatch = nostrPubkey // Convert hex to npub if needed for comparison if !nostrPubkey.hasPrefix("npub") { if let pubkeyData = Data(hexString: nostrPubkey), let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) { npubToMatch = encoded } else { SecureLogger.warning("⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", category: .session) } } for relationship in favorites { // Search through favorites for matching Nostr pubkey if let storedNostrKey = relationship.peerNostrPublicKey { // Compare against stored key (could be hex or npub) if storedNostrKey == npubToMatch { // SecureLogger.debug("✅ Found Noise key for Nostr sender (npub match)", category: .session) return relationship.peerNoisePublicKey } // Also try comparing raw hex if stored key is hex if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey { SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session) return relationship.peerNoisePublicKey } } } SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session) return nil } func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) { // If we have a Noise key, try to route securely if possible, otherwise fallback to direct if let _ = key { // Ideally we would use MessageRouter here, but for simplicity in this direct callback: // check if we have an identity if let id = try? idBridge.getCurrentNostrIdentity() { let nt = NostrTransport(keychain: keychain, idBridge: idBridge) nt.senderPeerID = meshService.myPeerID nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id) } } else if let id = try? idBridge.getCurrentNostrIdentity() { // Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey let nt = NostrTransport(keychain: keychain, idBridge: idBridge) nt.senderPeerID = meshService.myPeerID nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id) SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session) } // Same for READ receipt if viewing if !wasReadBefore && selectedPrivateChatPeer == message.senderPeerID { if let _ = key { if let id = try? idBridge.getCurrentNostrIdentity() { let nt = NostrTransport(keychain: keychain, idBridge: idBridge) nt.senderPeerID = meshService.myPeerID nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id) } } else if let id = try? idBridge.getCurrentNostrIdentity() { let nt = NostrTransport(keychain: keychain, idBridge: idBridge) nt.senderPeerID = meshService.myPeerID nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id) SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session) } } } func handleFavoriteNotification(content: String, from nostrPubkey: String) { // Try to find Noise key associated with this Nostr pubkey guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return } let isFavorite = content.contains("FAVORITE:TRUE") let senderNickname = content.components(separatedBy: "|").last ?? "Unknown" // Update favorite status if isFavorite { FavoritesPersistenceService.shared.addFavorite( peerNoisePublicKey: senderNoiseKey, peerNostrPublicKey: nostrPubkey, peerNickname: senderNickname ) } else { // Only remove if we don't have it set locally // Logic handled by persistence service usually, here we just update remote state // Actually for now we just process the notification } // Extract Nostr public key if included var extractedNostrPubkey: String? = nil if let range = content.range(of: "NPUB:") { let suffix = content[range.upperBound...] let parts = suffix.components(separatedBy: "|") if let key = parts.first { extractedNostrPubkey = String(key) } } else if content.contains(":") { // Fallback: simple format FAVORITE:TRUE:npub... let parts = content.components(separatedBy: ":") if parts.count >= 3 { extractedNostrPubkey = String(parts[2]) } } SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session) // If they favorited us and provided their Nostr key, ensure it's stored if isFavorite && extractedNostrPubkey != nil { SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", category: .session) FavoritesPersistenceService.shared.addFavorite( peerNoisePublicKey: senderNoiseKey, peerNostrPublicKey: extractedNostrPubkey, peerNickname: senderNickname ) } // Show notification NotificationService.shared.sendLocalNotification( title: isFavorite ? "New Favorite" : "Favorite Removed", body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you", identifier: "fav-\(UUID().uuidString)" ) } func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { // Find peer Nostr key guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey), relationship.peerNostrPublicKey != nil else { SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session) return } let peerID = PeerID(hexData: noisePublicKey) // Route via message router messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) } private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { guard content.hasPrefix("bitchat1:") else { return nil } let encoded = String(content.dropFirst("bitchat1:".count)) let maxBytes = FileTransferLimits.maxFramedFileBytes // Base64url length upper bound for maxBytes (padded length; unpadded is <= this). let maxEncoded = ((maxBytes + 2) / 3) * 4 guard encoded.count <= maxEncoded else { return nil } guard let packetData = Self.base64URLDecode(encoded), packetData.count <= maxBytes else { return nil } return BitchatPacket.from(packetData) } // MARK: - Geohash Nickname Resolution (for /block in geohash) func nostrPubkeyForDisplayName(_ name: String) -> String? { // Look up current visible geohash participants for an exact displayName match for p in visibleGeohashPeople() { if p.displayName == name { return p.id } } // Also check nickname cache directly for (pub, nick) in geoNicknames { if nick == name { return pub } } return nil } func startGeohashDM(withPubkeyHex hex: String) { let convKey = PeerID(nostr_: hex) nostrKeyMapping[convKey] = hex startPrivateChat(with: convKey) } func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? { return nostrKeyMapping[senderID] } func geohashDisplayName(for convKey: PeerID) -> String { guard let full = nostrKeyMapping[convKey] else { return convKey.bare } return displayNameForNostrPubkey(full) } } ================================================ FILE: bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift ================================================ // // ChatViewModel+PrivateChat.swift // bitchat // // Private chat and media transfer logic for ChatViewModel // import Foundation import Combine import BitLogger import SwiftUI extension ChatViewModel { // MARK: - Private Chat Sending /// Sends an encrypted private message to a specific peer. /// - Parameters: /// - content: The message content to encrypt and send /// - peerID: The recipient's peer ID /// - Note: Automatically establishes Noise encryption if not already active @MainActor func sendPrivateMessage(_ content: String, to peerID: PeerID) { guard !content.isEmpty else { return } // Check if blocked if unifiedPeerService.isBlocked(peerID) { let nickname = meshService.peerNickname(peerID: peerID) ?? "user" addSystemMessage( String( format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"), locale: .current, nickname ) ) return } // Geohash DM routing: conversation keys start with "nostr_" if peerID.isGeoDM { sendGeohashDM(content, to: peerID) return } // Determine routing method and recipient nickname guard let noiseKey = Data(hexString: peerID.id) else { return } let isConnected = meshService.isPeerConnected(peerID) let isReachable = meshService.isPeerReachable(peerID) let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) let isMutualFavorite = favoriteStatus?.isMutual ?? false let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil // Get nickname from various sources var recipientNickname = meshService.peerNickname(peerID: 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(), isRelay: false, originalSender: nil, isPrivate: true, recipientNickname: recipientNickname, senderPeerID: meshService.myPeerID, mentions: nil, deliveryStatus: .sending ) // Add to local chat if privateChats[peerID] == nil { privateChats[peerID] = [] } privateChats[peerID]?.append(message) // Trigger UI update for sent message objectWillChange.send() // Send via appropriate transport (BLE if connected/reachable, else Nostr when possible) if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID) // Optimistically mark as sent for both transports; delivery/read will update subsequently if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { privateChats[peerID]?[idx].deliveryStatus = .sent } } else { // Update delivery status to failed if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { privateChats[peerID]?[index].deliveryStatus = .failed( reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable") ) } let name = recipientNickname ?? "user" addSystemMessage( String( format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"), locale: .current, name ) ) } } func sendGeohashDM(_ content: String, to peerID: PeerID) { guard case .location(let ch) = activeChannel else { addSystemMessage( String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel") ) return } let messageID = UUID().uuidString // Local echo in the DM thread let message = BitchatMessage( id: messageID, sender: nickname, content: content, timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: nickname, senderPeerID: meshService.myPeerID, deliveryStatus: .sending ) if privateChats[peerID] == nil { privateChats[peerID] = [] } privateChats[peerID]?.append(message) objectWillChange.send() // Resolve recipient hex from mapping guard let recipientHex = nostrKeyMapping[peerID] else { if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { privateChats[peerID]?[msgIdx].deliveryStatus = .failed( reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown") ) } return } // Respect geohash blocks if identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) { if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { privateChats[peerID]?[msgIdx].deliveryStatus = .failed( reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked") ) } addSystemMessage( String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked") ) return } // Send via Nostr using per-geohash identity do { let id = try idBridge.deriveIdentity(forGeohash: ch.geohash) // Prevent messaging ourselves if recipientHex.lowercased() == id.publicKeyHex.lowercased() { if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { privateChats[peerID]?[idx].deliveryStatus = .failed( reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself") ) } return } SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session) let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID) if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { privateChats[peerID]?[msgIdx].deliveryStatus = .sent } } catch { if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { privateChats[peerID]?[idx].deliveryStatus = .failed( reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error") ) } } } // MARK: - Private Chat Handling (Geohash/Ephemeral) func handlePrivateMessage( _ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date ) { guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } let messageId = pm.messageID SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session) sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) // Respect geohash blocks if identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { return } // Duplicate check if privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return } for (_, arr) in privateChats { if arr.contains(where: { $0.id == messageId }) { return } } let senderName = displayNameForNostrPubkey(senderPubkey) let msg = BitchatMessage( id: messageId, sender: senderName, content: pm.content, timestamp: messageTimestamp, isRelay: false, isPrivate: true, recipientNickname: nickname, senderPeerID: convKey, deliveryStatus: .delivered(to: nickname, at: Date()) ) if privateChats[convKey] == nil { privateChats[convKey] = [] } privateChats[convKey]?.append(msg) let isViewing = selectedPrivateChatPeer == convKey let wasReadBefore = sentReadReceipts.contains(messageId) let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage if shouldMarkUnread { unreadPrivateMessages.insert(convKey) } // Send READ if viewing this conversation if isViewing { sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) } // Notify for truly unread and recent messages when not viewing if !isViewing && shouldMarkUnread { NotificationService.shared.sendPrivateMessageNotification( from: senderName, message: pm.content, peerID: convKey ) } objectWillChange.send() } func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { privateChats[convKey]?[idx].deliveryStatus = .delivered(to: displayNameForNostrPubkey(senderPubkey), at: Date()) objectWillChange.send() SecureLogger.info("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session) } else { SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) } } func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { privateChats[convKey]?[idx].deliveryStatus = .read(by: displayNameForNostrPubkey(senderPubkey), at: Date()) objectWillChange.send() SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session) } else { SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) } } func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { guard !sentGeoDeliveryAcks.contains(messageId) else { return } let nt = NostrTransport(keychain: keychain, idBridge: idBridge) nt.senderPeerID = meshService.myPeerID nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id) sentGeoDeliveryAcks.insert(messageId) } func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { guard !sentReadReceipts.contains(messageId) else { return } let nt = NostrTransport(keychain: keychain, idBridge: idBridge) nt.senderPeerID = meshService.myPeerID nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id) sentReadReceipts.insert(messageId) } // MARK: - Media Transfers private enum MediaSendError: Error { case encodingFailed case tooLarge case copyFailed } @MainActor func sendVoiceNote(at url: URL) { guard canSendMediaInCurrentContext else { SecureLogger.info("Voice note blocked outside mesh/private context", category: .session) try? FileManager.default.removeItem(at: url) addSystemMessage("Voice notes are only available in mesh chats.") return } let targetPeer = selectedPrivateChatPeer let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer) let messageID = message.id let transferId = makeTransferID(messageID: messageID) Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } do { // Security H1: Check file size BEFORE reading into memory let attrs = try FileManager.default.attributesOfItem(atPath: url.path) guard let fileSize = attrs[.size] as? Int, fileSize <= FileTransferLimits.maxVoiceNoteBytes else { let size = (attrs[.size] as? Int) ?? 0 SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) try? FileManager.default.removeItem(at: url) await MainActor.run { self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large") } return } let data = try Data(contentsOf: url) let packet = BitchatFilePacket( fileName: url.lastPathComponent, fileSize: UInt64(data.count), mimeType: "audio/mp4", content: data ) guard packet.encode() != nil else { throw MediaSendError.encodingFailed } await MainActor.run { self.registerTransfer(transferId: transferId, messageID: messageID) if let peerID = targetPeer { self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) } else { self.meshService.sendFileBroadcast(packet, transferId: transferId) } } } catch { SecureLogger.error("Voice note send failed: \(error)", category: .session) await MainActor.run { self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note") } } } } @MainActor func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) { guard canSendMediaInCurrentContext else { SecureLogger.info("Image send blocked outside mesh/private context", category: .session) cleanup?() addSystemMessage("Images are only available in mesh chats.") return } let targetPeer = selectedPrivateChatPeer Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } var processedURL: URL? do { let outputURL = try ImageUtils.processImage(at: sourceURL) processedURL = outputURL let data = try Data(contentsOf: outputURL) guard data.count <= FileTransferLimits.maxImageBytes else { SecureLogger.warning("Processed image exceeds size limit (\(data.count) bytes)", category: .session) await MainActor.run { self.addSystemMessage("Image is too large to send.") } try? FileManager.default.removeItem(at: outputURL) return } let packet = BitchatFilePacket( fileName: outputURL.lastPathComponent, fileSize: UInt64(data.count), mimeType: "image/jpeg", content: data ) guard packet.encode() != nil else { throw MediaSendError.encodingFailed } await MainActor.run { let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer) let messageID = message.id let transferId = self.makeTransferID(messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID) if let peerID = targetPeer { self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) } else { self.meshService.sendFileBroadcast(packet, transferId: transferId) } } } catch { SecureLogger.error("Image send preparation failed: \(error)", category: .session) await MainActor.run { self.addSystemMessage("Failed to prepare image for sending.") } if let url = processedURL { try? FileManager.default.removeItem(at: url) } } } } @MainActor func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage { let timestamp = Date() let message: BitchatMessage if let peerID = targetPeer { message = BitchatMessage( sender: nickname, content: content, timestamp: timestamp, isRelay: false, originalSender: nil, isPrivate: true, recipientNickname: nicknameForPeer(peerID), senderPeerID: meshService.myPeerID, deliveryStatus: .sending ) var chats = privateChats chats[peerID, default: []].append(message) privateChats = chats trimMessagesIfNeeded() } else { let (displayName, senderPeerID) = currentPublicSender() message = BitchatMessage( sender: displayName, content: content, timestamp: timestamp, isRelay: false, originalSender: nil, isPrivate: false, recipientNickname: nil, senderPeerID: senderPeerID, deliveryStatus: .sending ) timelineStore.append(message, to: activeChannel) messages = timelineStore.messages(for: activeChannel) trimMessagesIfNeeded() } let key = deduplicationService.normalizedContentKey(message.content) deduplicationService.recordContentKey(key, timestamp: timestamp) objectWillChange.send() return message } @MainActor func registerTransfer(transferId: String, messageID: String) { transferIdToMessageIDs[transferId, default: []].append(messageID) messageIDToTransferId[messageID] = transferId } func makeTransferID(messageID: String) -> String { "\(messageID)-\(UUID().uuidString)" } @MainActor func clearTransferMapping(for messageID: String) { guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return } guard var queue = transferIdToMessageIDs[transferId] else { return } if !queue.isEmpty { if queue.first == messageID { queue.removeFirst() } else if let idx = queue.firstIndex(of: messageID) { queue.remove(at: idx) } } transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue } @MainActor func handleMediaSendFailure(messageID: String, reason: String) { updateMessageDeliveryStatus(messageID, status: .failed(reason: reason)) clearTransferMapping(for: messageID) } @MainActor func handleTransferEvent(_ event: TransferProgressManager.Event) { switch event { case .started(let id, let total): guard let messageID = transferIdToMessageIDs[id]?.first else { return } updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total)) case .updated(let id, let sent, let total): guard let messageID = transferIdToMessageIDs[id]?.first else { return } updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total)) case .completed(let id, _): guard let messageID = transferIdToMessageIDs[id]?.first else { return } updateMessageDeliveryStatus(messageID, status: .sent) clearTransferMapping(for: messageID) case .cancelled(let id, _, _): guard let messageID = transferIdToMessageIDs[id]?.first else { return } clearTransferMapping(for: messageID) removeMessage(withID: messageID, cleanupFile: true) } } func cleanupLocalFile(forMessage message: BitchatMessage) { // Check both outgoing and incoming directories for thorough cleanup let prefixes = ["[voice] ", "[image] ", "[file] "] let subdirs = ["voicenotes/outgoing", "voicenotes/incoming", "images/outgoing", "images/incoming", "files/outgoing", "files/incoming"] guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return } let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return } // Security: Extract only the last path component to prevent directory traversal let safeFilename = (rawFilename as NSString).lastPathComponent guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return } // Try all possible locations (outgoing and incoming) for subdir in subdirs { let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) // Security: Verify target is within expected directory before deletion guard target.path.hasPrefix(base.path) else { continue } do { try FileManager.default.removeItem(at: target) } catch CocoaError.fileNoSuchFile { // Expected - file not in this directory } catch { SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session) } } } func applicationFilesDirectory() throws -> URL { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesDir = base.appendingPathComponent("files", isDirectory: true) try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) return filesDir } @MainActor func cancelMediaSend(messageID: String) { if let transferId = messageIDToTransferId[messageID], let active = transferIdToMessageIDs[transferId]?.first, active == messageID { meshService.cancelTransfer(transferId) } clearTransferMapping(for: messageID) removeMessage(withID: messageID, cleanupFile: true) } @MainActor func deleteMediaMessage(messageID: String) { clearTransferMapping(for: messageID) removeMessage(withID: messageID, cleanupFile: true) } // MARK: - Private Chat Handling (Main) @MainActor func handlePrivateMessage( _ payload: NoisePayload, actualSenderNoiseKey: Data?, senderNickname: String, targetPeerID: PeerID, messageTimestamp: Date, senderPubkey: String ) { guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } let messageId = pm.messageID let messageContent = pm.content // Favorite/unfavorite notifications embedded as private messages if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") { if let key = actualSenderNoiseKey { handleFavoriteNotificationFromMesh(messageContent, from: PeerID(hexData: key), senderNickname: senderNickname) } return } if isDuplicateMessage(messageId, targetPeerID: targetPeerID) { return } let wasReadBefore = sentReadReceipts.contains(messageId) // Is viewing? var isViewingThisChat = false if selectedPrivateChatPeer == targetPeerID { isViewingThisChat = true } else if let selectedPeer = selectedPrivateChatPeer, let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer), let key = actualSenderNoiseKey, selectedPeerData.noisePublicKey == key { isViewingThisChat = true } // Recency check let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage let message = BitchatMessage( id: messageId, sender: senderNickname, content: messageContent, timestamp: messageTimestamp, isRelay: false, isPrivate: true, recipientNickname: nickname, senderPeerID: targetPeerID, deliveryStatus: .delivered(to: nickname, at: Date()) ) addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID) mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey) // Using simplified internal helper in this file (or make the main one internal) // sendDeliveryAckViaNostrEmbedded is in ChatViewModel+Nostr.swift and is internal. // However, it was missing in ChatViewModel+Nostr.swift in previous step check? // Wait, I added `sendDeliveryAckViaNostrEmbedded` to `ChatViewModel+Nostr.swift` in Step 19? // Let's re-check `ChatViewModel+Nostr.swift` content in my mind. // I see `sendDeliveryAckViaNostrEmbedded` in `ChatViewModel+Nostr.swift` in the output of step 33. // So I can call it. sendDeliveryAckViaNostrEmbedded( message, wasReadBefore: wasReadBefore, senderPubkey: senderPubkey, key: actualSenderNoiseKey ) if wasReadBefore { // do nothing } else if isViewingThisChat { handleViewingThisChat( message, targetPeerID: targetPeerID, key: actualSenderNoiseKey, senderPubkey: senderPubkey ) } else { markAsUnreadIfNeeded( shouldMarkAsUnread: shouldMarkAsUnread, targetPeerID: targetPeerID, key: actualSenderNoiseKey, isRecentMessage: isRecentMessage, senderNickname: senderNickname, messageContent: messageContent ) } objectWillChange.send() } /// Handle incoming private message (Mesh) @MainActor func handlePrivateMessage(_ message: BitchatMessage) { SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) guard let peerID = senderPeerID else { SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session) 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.id.count == 16 { // This is an ephemeral peer ID (8 bytes = 16 hex chars) if let peer = unifiedPeerService.getPeer(by: peerID) { let stableKeyHex = PeerID(hexData: peer.noisePublicKey) // 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.info("📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", category: .session) } } } // Avoid duplicates if isDuplicateMessage(message.id, targetPeerID: peerID) { return } // Store the message addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID) // Mirror to ephemeral if needed (if we are talking to a stable key peer but have an ephemeral session) // Actually, logic usually mirrors TO stable key storage if available? // Or mirrors to ephemeral if we received on stable. // Let's just use the existing helper which seems to mirror TO ephemeral. // But we need to get the noise key. let noiseKey = peerID.noiseKey ?? unifiedPeerService.getPeer(by: peerID)?.noisePublicKey mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey) // Notifications and Read Receipts let isViewing = selectedPrivateChatPeer == peerID if isViewing { // Mark read immediately if viewing // Use the incoming peerID directly - it has the established Noise session. // Don't use PeerID(hexData: noiseKey) as that creates a 64-hex ID without a session. // Use meshService directly (not messageRouter) so it queues if peer disconnects. let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname) meshService.sendReadReceipt(receipt, to: peerID) sentReadReceipts.insert(message.id) } else { // Notify unreadPrivateMessages.insert(peerID) NotificationService.shared.sendPrivateMessageNotification( from: message.sender, message: message.content, peerID: peerID ) } objectWillChange.send() } func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool { if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { return true } for (_, messages) in privateChats where messages.contains(where: { $0.id == messageId }) { return true } return false } func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) { if privateChats[targetPeerID] == nil { privateChats[targetPeerID] = [] } if let idx = privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) { privateChats[targetPeerID]?[idx] = message } else { privateChats[targetPeerID]?.append(message) } // Sanitize to avoid duplicate IDs privateChatManager.sanitizeChat(for: targetPeerID) } @MainActor func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) { guard let key, let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, ephemeralPeerID != targetPeerID else { return } if privateChats[ephemeralPeerID] == nil { privateChats[ephemeralPeerID] = [] } if let idx = privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) { privateChats[ephemeralPeerID]?[idx] = message } else { privateChats[ephemeralPeerID]?.append(message) } privateChatManager.sanitizeChat(for: ephemeralPeerID) } @MainActor func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?, senderPubkey: String) { unreadPrivateMessages.remove(targetPeerID) if let key, let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID { unreadPrivateMessages.remove(ephemeralPeerID) } if !sentReadReceipts.contains(message.id) { if let key { let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname) SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session) messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key)) sentReadReceipts.insert(message.id) } else if let id = try? idBridge.getCurrentNostrIdentity() { let nt = NostrTransport(keychain: keychain, idBridge: idBridge) nt.senderPeerID = meshService.myPeerID nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id) sentReadReceipts.insert(message.id) SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session) } } } @MainActor func markAsUnreadIfNeeded( shouldMarkAsUnread: Bool, targetPeerID: PeerID, key: Data?, isRecentMessage: Bool, senderNickname: String, messageContent: String ) { guard shouldMarkAsUnread else { return } unreadPrivateMessages.insert(targetPeerID) if let key, let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, ephemeralPeerID != targetPeerID { unreadPrivateMessages.insert(ephemeralPeerID) } if isRecentMessage { NotificationService.shared.sendPrivateMessageNotification( from: senderNickname, message: messageContent, peerID: targetPeerID ) } } @MainActor func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, 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.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session) } // Get the noise public key for this peer let noiseKey = peerID.noiseKey ?? unifiedPeerService.getPeer(by: peerID)?.noisePublicKey guard let finalNoiseKey = noiseKey else { SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session) return } // Determine prior state to avoid duplicate system messages on repeated notifications let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false // Update the favorite relationship (idempotent storage) FavoritesPersistenceService.shared.updatePeerFavoritedUs( peerNoisePublicKey: finalNoiseKey, favorited: isFavorite, peerNickname: senderNickname, peerNostrPublicKey: nostrPubkey ) // If they favorited us and provided their Nostr key, ensure it's stored (log only) if isFavorite && nostrPubkey != nil { SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", category: .session) } // Only show a system message when the state changes, and only in mesh if prior != isFavorite { let action = isFavorite ? "favorited" : "unfavorited" addMeshOnlySystemMessage("\(senderNickname) \(action) you") } } /// Process action messages (hugs, slaps) into system messages 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 func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) { let currentFingerprint = getFingerprint(for: peerID) if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true { var migratedMessages: [BitchatMessage] = [] var oldPeerIDsToRemove: [PeerID] = [] // Only migrate messages from the last 24 hours to prevent old messages from flooding let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds) 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.info("📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)", category: .session) } SecureLogger.info("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)", category: .session) } 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.warning("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)", category: .session) } } } } // Remove old peer ID entries if !oldPeerIDsToRemove.isEmpty { // Track if we need to update selectedPrivateChatPeer let needsSelectedUpdate = oldPeerIDsToRemove.contains { selectedPrivateChatPeer == $0 } for oldID in oldPeerIDsToRemove { privateChats.removeValue(forKey: oldID) unreadPrivateMessages.remove(oldID) // Also clean up fingerprint mapping if peerIDToPublicKeyFingerprint[oldID] != nil { peerIDToPublicKeyFingerprint.removeValue(forKey: oldID) } } if needsSelectedUpdate { selectedPrivateChatPeer = peerID } } // Add migrated messages to new peer ID if !migratedMessages.isEmpty { if privateChats[peerID] == nil { privateChats[peerID] = [] } privateChats[peerID]?.append(contentsOf: migratedMessages) // Sort by timestamp privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } // De-duplicate just in case privateChatManager.sanitizeChat(for: peerID) objectWillChange.send() } } } @MainActor func sendFavoriteNotification(to peerID: PeerID, 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.id) { 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) { messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session) } else if let key = noiseKey { // Send via Nostr for offline peers (using router) messageRouter.sendFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite) } else { SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session) } } /// Check if a message should be blocked based on sender @MainActor func isMessageBlocked(_ message: BitchatMessage) -> Bool { if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) { // Check mesh/known peers first if isPeerBlocked(peerID) { return true } // Check geohash (Nostr) blocks using mapping to full pubkey if peerID.isGeoChat || peerID.isGeoDM { if let full = nostrKeyMapping[peerID]?.lowercased() { if identityManager.isNostrBlocked(pubkeyHexLowercased: full) { return true } } } return false } return false } } ================================================ FILE: bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift ================================================ // // ChatViewModel+Tor.swift // bitchat // // Tor lifecycle handling for ChatViewModel // import Foundation import Combine import Tor extension ChatViewModel { // MARK: - Tor notifications @objc func handleTorWillStart() { Task { @MainActor in if !self.torStatusAnnounced && TorManager.shared.torEnforced { self.torStatusAnnounced = true // Post only in geohash channels (queue if not active) self.addGeohashOnlySystemMessage( String(localized: "system.tor.starting", comment: "System message when Tor is starting") ) } } } @objc func handleTorWillRestart() { Task { @MainActor in self.torRestartPending = true // Post only in geohash channels (queue if not active) self.addGeohashOnlySystemMessage( String(localized: "system.tor.restarting", comment: "System message when Tor is restarting") ) } } @objc func handleTorDidBecomeReady() { Task { @MainActor in // Only announce "restarted" if we actually restarted this session if self.torRestartPending { // Post only in geohash channels (queue if not active) self.addGeohashOnlySystemMessage( String(localized: "system.tor.restarted", comment: "System message when Tor has restarted") ) self.torRestartPending = false } else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced { // Initial start completed self.addGeohashOnlySystemMessage( String(localized: "system.tor.started", comment: "System message when Tor has started") ) self.torInitialReadyAnnounced = true } } } @objc func handleTorPreferenceChanged(_ notification: Notification) { Task { @MainActor in self.torStatusAnnounced = false self.torInitialReadyAnnounced = false self.torRestartPending = false } } } ================================================ FILE: bitchat/ViewModels/Extensions/README.md ================================================ # ChatViewModel Extensions This directory contains extensions to `ChatViewModel` to modularize its functionality. - `ChatViewModel+Tor.swift`: Handles Tor lifecycle events and notifications. - `ChatViewModel+PrivateChat.swift`: Manages private chat logic, media transfers (images, voice notes), and file handling. - `ChatViewModel+Nostr.swift`: Contains all logic related to Nostr integration, Geohash channels, and Nostr identity management. The main `ChatViewModel.swift` retains core state, initialization, and coordination logic. ================================================ FILE: bitchat/ViewModels/GeoChannelCoordinator.swift ================================================ // // GeoChannelCoordinator.swift // bitchat // // Centralizes Combine wiring for location channel selection and sampling. // import Combine import Foundation import Tor @MainActor final class GeoChannelCoordinator { private let locationManager: LocationChannelManager private let bookmarksStore: GeohashBookmarksStore private let torManager: TorManager private let onChannelSwitch: (ChannelID) -> Void private let beginSampling: ([String]) -> Void private let endSampling: () -> Void private var cancellables = Set() private var regionalGeohashes: [String] = [] private var bookmarkedGeohashes: [String] = [] init( locationManager: LocationChannelManager? = nil, bookmarksStore: GeohashBookmarksStore? = nil, torManager: TorManager? = nil, onChannelSwitch: @escaping (ChannelID) -> Void, beginSampling: @escaping ([String]) -> Void, endSampling: @escaping () -> Void ) { self.locationManager = locationManager ?? Self.defaultLocationManager() self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared self.torManager = torManager ?? Self.defaultTorManager() self.onChannelSwitch = onChannelSwitch self.beginSampling = beginSampling self.endSampling = endSampling start() } func start() { regionalGeohashes = locationManager.availableChannels.map { $0.geohash } bookmarkedGeohashes = bookmarksStore.bookmarks locationManager.$selectedChannel .receive(on: DispatchQueue.main) .sink { [weak self] channel in guard let self else { return } Task { @MainActor in self.onChannelSwitch(channel) } } .store(in: &cancellables) locationManager.$availableChannels .receive(on: DispatchQueue.main) .sink { [weak self] channels in guard let self else { return } self.regionalGeohashes = channels.map { $0.geohash } self.updateSampling() } .store(in: &cancellables) bookmarksStore.$bookmarks .receive(on: DispatchQueue.main) .sink { [weak self] bookmarks in guard let self else { return } self.bookmarkedGeohashes = bookmarks self.updateSampling() } .store(in: &cancellables) locationManager.$permissionState .receive(on: DispatchQueue.main) .sink { [weak self] state in guard let self, state == .authorized else { return } Task { @MainActor [weak self] in self?.locationManager.refreshChannels() } } .store(in: &cancellables) Task { @MainActor in self.onChannelSwitch(self.locationManager.selectedChannel) } updateSampling() } private func updateSampling() { let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes)) Task { @MainActor in guard !union.isEmpty else { endSampling() return } if torManager.isForeground() { beginSampling(union) } else { endSampling() } } } func refreshSampling() { updateSampling() } private static func defaultLocationManager() -> LocationChannelManager { LocationChannelManager.shared } @MainActor private static func defaultTorManager() -> TorManager { TorManager.shared } } ================================================ FILE: bitchat/ViewModels/MessageRateLimiter.swift ================================================ // // MessageRateLimiter.swift // bitchat // // Handles per-sender and per-content token buckets for public message intake. // import Foundation struct MessageRateLimiter { private struct TokenBucket { var capacity: Double var tokens: Double var refillPerSec: Double var lastRefill: Date mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool { let dt = now.timeIntervalSince(lastRefill) if dt > 0 { tokens = min(capacity, tokens + dt * refillPerSec) lastRefill = now } if tokens >= cost { tokens -= cost return true } return false } } private var senderBuckets: [String: TokenBucket] = [:] private var contentBuckets: [String: TokenBucket] = [:] private let senderCapacity: Double private let senderRefill: Double private let contentCapacity: Double private let contentRefill: Double init( senderCapacity: Double, senderRefillPerSec: Double, contentCapacity: Double, contentRefillPerSec: Double ) { self.senderCapacity = senderCapacity self.senderRefill = senderRefillPerSec self.contentCapacity = contentCapacity self.contentRefill = contentRefillPerSec } mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool { var senderBucket = senderBuckets[senderKey] ?? TokenBucket( capacity: senderCapacity, tokens: senderCapacity, refillPerSec: senderRefill, lastRefill: now ) let senderAllowed = senderBucket.allow(now: now) senderBuckets[senderKey] = senderBucket var contentBucket = contentBuckets[contentKey] ?? TokenBucket( capacity: contentCapacity, tokens: contentCapacity, refillPerSec: contentRefill, lastRefill: now ) let contentAllowed = contentBucket.allow(now: now) contentBuckets[contentKey] = contentBucket return senderAllowed && contentAllowed } mutating func reset() { senderBuckets.removeAll() contentBuckets.removeAll() } } ================================================ FILE: bitchat/ViewModels/MinimalDistancePalette.swift ================================================ // // MinimalDistancePalette.swift // bitchat // // Lightweight palette generator that keeps peer colors evenly spaced. // import Foundation import SwiftUI final class MinimalDistancePalette { struct Config { let slotCount: Int let avoidCenterHue: Double let avoidHueDelta: Double let saturationLight: Double let saturationDark: Double let baseBrightnessLight: Double let baseBrightnessDark: Double let ringBrightnessDeltaLight: Double let ringBrightnessDeltaDark: Double let preferredBiasWeight: Double let goldenStep: Int init( slotCount: Int, avoidCenterHue: Double, avoidHueDelta: Double, saturationLight: Double, saturationDark: Double, baseBrightnessLight: Double, baseBrightnessDark: Double, ringBrightnessDeltaLight: Double, ringBrightnessDeltaDark: Double, preferredBiasWeight: Double = 0.05, goldenStep: Int = 7 ) { self.slotCount = slotCount self.avoidCenterHue = avoidCenterHue self.avoidHueDelta = avoidHueDelta self.saturationLight = saturationLight self.saturationDark = saturationDark self.baseBrightnessLight = baseBrightnessLight self.baseBrightnessDark = baseBrightnessDark self.ringBrightnessDeltaLight = ringBrightnessDeltaLight self.ringBrightnessDeltaDark = ringBrightnessDeltaDark self.preferredBiasWeight = preferredBiasWeight self.goldenStep = goldenStep } } private struct Entry { let slot: Int let ring: Int let hue: Double } private let config: Config private var currentSeeds: [String: String] = [:] private var entries: [String: Entry] = [:] private var previousEntries: [String: Entry] = [:] init(config: Config) { self.config = config } @MainActor func ensurePalette(for seeds: [String: String]) { guard seeds != currentSeeds || entries.count != seeds.count else { return } previousEntries = entries currentSeeds = seeds rebuildEntries() } @MainActor func color(for identifier: String, isDark: Bool) -> Color? { guard let entry = entries[identifier] else { return nil } let saturation = isDark ? config.saturationDark : config.saturationLight let baseBrightness = isDark ? config.baseBrightnessDark : config.baseBrightnessLight let ringDelta = isDark ? config.ringBrightnessDeltaDark : config.ringBrightnessDeltaLight let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(entry.ring))) return Color(hue: entry.hue, saturation: saturation, brightness: brightness) } @MainActor func reset() { currentSeeds.removeAll() entries.removeAll() previousEntries.removeAll() } @MainActor private func rebuildEntries() { guard !currentSeeds.isEmpty else { entries.removeAll() return } let slotCount = max(8, config.slotCount) var slots: [Double] = [] for idx in 0.. Double { let diff = abs(a - b) return diff > 0.5 ? 1.0 - diff : diff } let peerIDs = currentSeeds.keys.sorted() let preferredIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peerIDs.map { id in let seed = currentSeeds[id] ?? id let hash = seed.djb2() let index = Int(hash % UInt64(slots.count)) return (id, index) }) var mapping: [String: Entry] = [:] var usedSlots = Set() var usedHues: [Double] = [] let prior = entries.isEmpty ? previousEntries : entries for (id, entry) in prior { guard currentSeeds.keys.contains(id), entry.slot < slots.count else { continue } let hue = slots[entry.slot] mapping[id] = Entry(slot: entry.slot, ring: entry.ring, hue: hue) usedSlots.insert(entry.slot) usedHues.append(hue) } let unassigned = peerIDs.filter { mapping[$0] == nil } for id in unassigned { let preferred = preferredIndex[id] ?? 0 if !usedSlots.contains(preferred), preferred < slots.count { let hue = slots[preferred] mapping[id] = Entry(slot: preferred, ring: 0, hue: hue) usedSlots.insert(preferred) usedHues.append(hue) continue } var bestSlot: Int? var bestScore = -Double.infinity for slot in 0.. bestScore { bestScore = score bestSlot = slot } } if let slot = bestSlot { let hue = slots[slot] mapping[id] = Entry(slot: slot, ring: 0, hue: hue) usedSlots.insert(slot) usedHues.append(hue) } } let remaining = peerIDs.filter { mapping[$0] == nil } if !remaining.isEmpty { for (index, id) in remaining.enumerated() { let preferred = preferredIndex[id] ?? 0 let slot = (preferred + index * config.goldenStep) % slots.count let hue = slots[slot] mapping[id] = Entry(slot: slot, ring: 1, hue: hue) } } entries = mapping } } extension MinimalDistancePalette.Config { static let mesh = MinimalDistancePalette.Config( slotCount: TransportConfig.uiPeerPaletteSlots, avoidCenterHue: 30.0 / 360.0, avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta, saturationLight: 0.70, saturationDark: 0.80, baseBrightnessLight: 0.45, baseBrightnessDark: 0.75, ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight, ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark ) static let nostr = MinimalDistancePalette.Config( slotCount: TransportConfig.uiPeerPaletteSlots, avoidCenterHue: 30.0 / 360.0, avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta, saturationLight: 0.70, saturationDark: 0.80, baseBrightnessLight: 0.45, baseBrightnessDark: 0.75, ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight, ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark ) } ================================================ FILE: bitchat/ViewModels/PublicMessagePipeline.swift ================================================ // // PublicMessagePipeline.swift // bitchat // // Handles batching and deduplication of public chat messages before surfacing them to the UI. // import Foundation @MainActor protocol PublicMessagePipelineDelegate: AnyObject { func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) } @MainActor final class PublicMessagePipeline { weak var delegate: PublicMessagePipelineDelegate? private var buffer: [BitchatMessage] = [] private var timer: Timer? private let baseFlushInterval: TimeInterval private var dynamicFlushInterval: TimeInterval private var recentBatchSizes: [Int] = [] private let maxRecentBatchSamples: Int private let dedupWindow: TimeInterval private var activeChannel: ChannelID = .mesh init( baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval, maxRecentBatchSamples: Int = 10, dedupWindow: TimeInterval = 1.0 ) { self.baseFlushInterval = baseFlushInterval self.dynamicFlushInterval = baseFlushInterval self.maxRecentBatchSamples = maxRecentBatchSamples self.dedupWindow = dedupWindow } deinit { timer?.invalidate() } func updateActiveChannel(_ channel: ChannelID) { activeChannel = channel } func enqueue(_ message: BitchatMessage) { buffer.append(message) scheduleFlush() } func flushIfNeeded() { flushBuffer() } func reset() { timer?.invalidate() timer = nil buffer.removeAll(keepingCapacity: false) } } private extension PublicMessagePipeline { func scheduleFlush() { guard timer == nil else { return } timer = Timer.scheduledTimer(withTimeInterval: dynamicFlushInterval, repeats: false) { [weak self] _ in guard let self else { return } Task { @MainActor in self.flushBuffer() } } } func flushBuffer() { timer?.invalidate() timer = nil guard !buffer.isEmpty else { return } guard let delegate = delegate else { buffer.removeAll(keepingCapacity: false) return } delegate.pipelineSetBatchingState(self, isBatching: true) var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id }) var pending: [(message: BitchatMessage, contentKey: String)] = [] var batchContentLatest: [String: Date] = [:] for message in buffer { if existingIDs.contains(message.id) { continue } let contentKey = delegate.pipeline(self, normalizeContent: message.content) if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey), abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow { continue } if let ts = batchContentLatest[contentKey], abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow { continue } existingIDs.insert(message.id) pending.append((message, contentKey)) batchContentLatest[contentKey] = message.timestamp } buffer.removeAll(keepingCapacity: true) guard !pending.isEmpty else { delegate.pipelineSetBatchingState(self, isBatching: false) if !buffer.isEmpty { scheduleFlush() } return } pending.sort { $0.message.timestamp < $1.message.timestamp } var messages = delegate.pipelineCurrentMessages(self) let threshold = lateInsertThreshold(for: activeChannel) let lastTimestamp = messages.last?.timestamp ?? .distantPast for item in pending { let message = item.message if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) { let index = insertionIndex(for: message.timestamp, in: messages) if index >= messages.count { messages.append(message) } else { messages.insert(message, at: index) } } else { messages.append(message) } delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp) } delegate.pipeline(self, setMessages: messages) delegate.pipelineTrimMessages(self) updateFlushInterval(withBatchSize: pending.count) for item in pending { delegate.pipelinePrewarmMessage(self, message: item.message) } delegate.pipelineSetBatchingState(self, isBatching: false) if !buffer.isEmpty { scheduleFlush() } } func updateFlushInterval(withBatchSize size: Int) { recentBatchSizes.append(size) if recentBatchSizes.count > maxRecentBatchSamples { recentBatchSizes.removeFirst(recentBatchSizes.count - maxRecentBatchSamples) } let avg = recentBatchSizes.isEmpty ? 0.0 : Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count) dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval } func lateInsertThreshold(for channel: ChannelID) -> TimeInterval { switch channel { case .mesh: return TransportConfig.uiLateInsertThreshold case .location: return TransportConfig.uiLateInsertThresholdGeo } } func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> Int { var low = 0 var high = messages.count while low < high { let mid = (low + high) / 2 if messages[mid].timestamp < timestamp { low = mid + 1 } else { high = mid } } return low } } ================================================ FILE: bitchat/ViewModels/PublicTimelineStore.swift ================================================ // // PublicTimelineStore.swift // bitchat // // Maintains mesh and geohash public timelines with simple caps and helpers. // import Foundation struct PublicTimelineStore { private var meshTimeline: [BitchatMessage] = [] private var geohashTimelines: [String: [BitchatMessage]] = [:] private var pendingGeohashSystemMessages: [String] = [] private let meshCap: Int private let geohashCap: Int init(meshCap: Int, geohashCap: Int) { self.meshCap = meshCap self.geohashCap = geohashCap } mutating func append(_ message: BitchatMessage, to channel: ChannelID) { switch channel { case .mesh: guard !meshTimeline.contains(where: { $0.id == message.id }) else { return } meshTimeline.append(message) trimMeshTimelineIfNeeded() case .location(let channel): append(message, toGeohash: channel.geohash) } } mutating func append(_ message: BitchatMessage, toGeohash geohash: String) { var timeline = geohashTimelines[geohash] ?? [] guard !timeline.contains(where: { $0.id == message.id }) else { return } timeline.append(message) trimGeohashTimelineIfNeeded(&timeline) geohashTimelines[geohash] = timeline } /// Append message if absent, returning true when stored. mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { var timeline = geohashTimelines[geohash] ?? [] guard !timeline.contains(where: { $0.id == message.id }) else { return false } timeline.append(message) trimGeohashTimelineIfNeeded(&timeline) geohashTimelines[geohash] = timeline return true } mutating func messages(for channel: ChannelID) -> [BitchatMessage] { switch channel { case .mesh: return meshTimeline case .location(let channel): let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? [] geohashTimelines[channel.geohash] = cleaned return cleaned } } mutating func clear(channel: ChannelID) { switch channel { case .mesh: meshTimeline.removeAll() case .location(let channel): geohashTimelines[channel.geohash] = [] } } @discardableResult mutating func removeMessage(withID id: String) -> BitchatMessage? { if let index = meshTimeline.firstIndex(where: { $0.id == id }) { return meshTimeline.remove(at: index) } for key in Array(geohashTimelines.keys) { var timeline = geohashTimelines[key] ?? [] if let index = timeline.firstIndex(where: { $0.id == id }) { let removed = timeline.remove(at: index) geohashTimelines[key] = timeline.isEmpty ? nil : timeline return removed } } return nil } mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) { var timeline = geohashTimelines[geohash] ?? [] timeline.removeAll(where: predicate) geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline } mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) { var timeline = geohashTimelines[geohash] ?? [] transform(&timeline) geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline } mutating func queueGeohashSystemMessage(_ content: String) { pendingGeohashSystemMessages.append(content) } mutating func drainPendingGeohashSystemMessages() -> [String] { defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) } return pendingGeohashSystemMessages } func geohashKeys() -> [String] { Array(geohashTimelines.keys) } private mutating func trimMeshTimelineIfNeeded() { guard meshTimeline.count > meshCap else { return } meshTimeline = Array(meshTimeline.suffix(meshCap)) } private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) { guard timeline.count > geohashCap else { return } timeline = Array(timeline.suffix(geohashCap)) } } ================================================ FILE: bitchat/Views/AppInfoView.swift ================================================ import SwiftUI struct AppInfoView: View { @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white } private var textColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } private var secondaryTextColor: Color { colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) } // MARK: - Constants private enum Strings { static let appName: LocalizedStringKey = "app_info.app_name" static let tagline: LocalizedStringKey = "app_info.tagline" enum Features { static let title: LocalizedStringKey = "app_info.features.title" static let offlineComm = AppInfoFeatureInfo( icon: "wifi.slash", title: "app_info.features.offline.title", description: "app_info.features.offline.description" ) static let encryption = AppInfoFeatureInfo( icon: "lock.shield", title: "app_info.features.encryption.title", description: "app_info.features.encryption.description" ) static let extendedRange = AppInfoFeatureInfo( icon: "antenna.radiowaves.left.and.right", title: "app_info.features.extended_range.title", description: "app_info.features.extended_range.description" ) static let mentions = AppInfoFeatureInfo( icon: "at", title: "app_info.features.mentions.title", description: "app_info.features.mentions.description" ) static let favorites = AppInfoFeatureInfo( icon: "star.fill", title: "app_info.features.favorites.title", description: "app_info.features.favorites.description" ) static let geohash = AppInfoFeatureInfo( icon: "number", title: "app_info.features.geohash.title", description: "app_info.features.geohash.description" ) } enum Privacy { static let title: LocalizedStringKey = "app_info.privacy.title" static let noTracking = AppInfoFeatureInfo( icon: "eye.slash", title: "app_info.privacy.no_tracking.title", description: "app_info.privacy.no_tracking.description" ) static let ephemeral = AppInfoFeatureInfo( icon: "shuffle", title: "app_info.privacy.ephemeral.title", description: "app_info.privacy.ephemeral.description" ) static let panic = AppInfoFeatureInfo( icon: "hand.raised.fill", title: "app_info.privacy.panic.title", description: "app_info.privacy.panic.description" ) } enum HowToUse { static let title: LocalizedStringKey = "app_info.how_to_use.title" static let instructions: [LocalizedStringKey] = [ "app_info.how_to_use.set_nickname", "app_info.how_to_use.change_channels", "app_info.how_to_use.open_sidebar", "app_info.how_to_use.start_dm", "app_info.how_to_use.clear_chat", "app_info.how_to_use.commands" ] } } var body: some View { #if os(macOS) VStack(spacing: 0) { // Custom header for macOS HStack { Spacer() Button("app_info.done") { dismiss() } .buttonStyle(.plain) .foregroundColor(textColor) .padding() } .background(backgroundColor.opacity(0.95)) ScrollView { infoContent } .background(backgroundColor) } .frame(width: 600, height: 700) #else NavigationView { ScrollView { infoContent } .background(backgroundColor) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { dismiss() }) { Image(systemName: "xmark") .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .foregroundColor(textColor) .frame(width: 32, height: 32) } .buttonStyle(.plain) .accessibilityLabel("app_info.close") } } } #endif } @ViewBuilder private var infoContent: some View { VStack(alignment: .leading, spacing: 24) { // Header VStack(alignment: .center, spacing: 8) { Text(Strings.appName) .font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced)) .foregroundColor(textColor) Text(Strings.tagline) .font(.bitchatSystem(size: 16, design: .monospaced)) .foregroundColor(secondaryTextColor) } .frame(maxWidth: .infinity) .padding(.vertical) // How to Use VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.HowToUse.title) VStack(alignment: .leading, spacing: 8) { ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \.offset) { _, instruction in Text(instruction) } } .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(textColor) } // Features VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.Features.title) FeatureRow(info: Strings.Features.offlineComm) FeatureRow(info: Strings.Features.encryption) FeatureRow(info: Strings.Features.extendedRange) FeatureRow(info: Strings.Features.favorites) FeatureRow(info: Strings.Features.geohash) FeatureRow(info: Strings.Features.mentions) } // Privacy VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.Privacy.title) FeatureRow(info: Strings.Privacy.noTracking) FeatureRow(info: Strings.Privacy.ephemeral) FeatureRow(info: Strings.Privacy.panic) } } .padding() } } struct AppInfoFeatureInfo { let icon: String let title: LocalizedStringKey let description: LocalizedStringKey } struct SectionHeader: View { let title: LocalizedStringKey @Environment(\.colorScheme) var colorScheme private var textColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } init(_ title: LocalizedStringKey) { self.title = title } var body: some View { Text(title) .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .foregroundColor(textColor) .padding(.top, 8) } } struct FeatureRow: View { let info: AppInfoFeatureInfo @Environment(\.colorScheme) var colorScheme private var textColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } private var secondaryTextColor: Color { colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) } var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: info.icon) .font(.bitchatSystem(size: 20)) .foregroundColor(textColor) .frame(width: 30) VStack(alignment: .leading, spacing: 4) { Text(info.title) .font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced)) .foregroundColor(textColor) Text(info.description) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(secondaryTextColor) .fixedSize(horizontal: false, vertical: true) } Spacer() } } } #Preview("Default") { AppInfoView() } #Preview("Dynamic Type XXL") { AppInfoView() .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge) } #Preview("Dynamic Type XS") { AppInfoView() .environment(\.sizeCategory, .extraSmall) } ================================================ FILE: bitchat/Views/Components/CommandSuggestionsView.swift ================================================ // // CommandSuggestionsView.swift // bitchat // // Created by Islam on 29/10/2025. // import SwiftUI struct CommandSuggestionsView: View { @EnvironmentObject private var viewModel: ChatViewModel @ObservedObject private var locationManager = LocationChannelManager.shared @Binding var messageText: String let textColor: Color let backgroundColor: Color let secondaryTextColor: Color private var filteredCommands: [CommandInfo] { guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] } let isGeoPublic = locationManager.selectedChannel.isLocation let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in command.alias.starts(with: messageText.lowercased()) } } var body: some View { VStack(alignment: .leading, spacing: 0) { ForEach(filteredCommands) { command in Button { messageText = command.alias + " " } label: { buttonRow(for: command) } .buttonStyle(.plain) .background(Color.gray.opacity(0.1)) } } .background(backgroundColor) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1) ) } private func buttonRow(for command: CommandInfo) -> some View { HStack { Text(command.alias) .font(.bitchatSystem(size: 11, design: .monospaced)) .foregroundColor(textColor) .fontWeight(.medium) if let placeholder = command.placeholder { Text(placeholder) .font(.bitchatSystem(size: 10, design: .monospaced)) .foregroundColor(secondaryTextColor.opacity(0.8)) } Spacer() Text(command.description) .font(.bitchatSystem(size: 10, design: .monospaced)) .foregroundColor(secondaryTextColor) } .padding(.horizontal, 12) .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading) } } @available(iOS 17, macOS 14, *) #Preview { @Previewable @State var messageText: String = "/" let keychain = KeychainManager() let viewModel = ChatViewModel( keychain: keychain, idBridge: NostrIdentityBridge(), identityManager: SecureIdentityStateManager(keychain) ) CommandSuggestionsView( messageText: $messageText, textColor: .green, backgroundColor: .primary, secondaryTextColor: .secondary ) .environmentObject(viewModel) } ================================================ FILE: bitchat/Views/Components/DeliveryStatusView.swift ================================================ // // DeliveryStatusView.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import SwiftUI struct DeliveryStatusView: View { @Environment(\.colorScheme) private var colorScheme let status: DeliveryStatus // MARK: - Computed Properties private var textColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } private var secondaryTextColor: Color { colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) } private enum Strings { static func delivered(to nickname: String) -> String { String( format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"), locale: .current, nickname ) } static func read(by nickname: String) -> String { String( format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"), locale: .current, nickname ) } static func failed(_ reason: String) -> String { String( format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"), locale: .current, reason ) } static func deliveredToMembers(_ reached: Int, _ total: Int) -> String { String( format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"), locale: .current, reached, total ) } } // MARK: - Body var body: some View { switch status { case .sending: Image(systemName: "circle") .font(.bitchatSystem(size: 10)) .foregroundColor(secondaryTextColor.opacity(0.6)) case .sent: Image(systemName: "checkmark") .font(.bitchatSystem(size: 10)) .foregroundColor(secondaryTextColor.opacity(0.6)) case .delivered(let nickname, _): HStack(spacing: -2) { Image(systemName: "checkmark") .font(.bitchatSystem(size: 10)) Image(systemName: "checkmark") .font(.bitchatSystem(size: 10)) } .foregroundColor(textColor.opacity(0.8)) .help(Strings.delivered(to: nickname)) case .read(let nickname, _): HStack(spacing: -2) { Image(systemName: "checkmark") .font(.bitchatSystem(size: 10, weight: .bold)) Image(systemName: "checkmark") .font(.bitchatSystem(size: 10, weight: .bold)) } .foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue .help(Strings.read(by: nickname)) case .failed(let reason): Image(systemName: "exclamationmark.triangle") .font(.bitchatSystem(size: 10)) .foregroundColor(Color.red.opacity(0.8)) .help(Strings.failed(reason)) case .partiallyDelivered(let reached, let total): HStack(spacing: 1) { Image(systemName: "checkmark") .font(.bitchatSystem(size: 10)) Text(verbatim: "\(reached)/\(total)") .font(.bitchatSystem(size: 10, design: .monospaced)) } .foregroundColor(secondaryTextColor.opacity(0.6)) .help(Strings.deliveredToMembers(reached, total)) } } } #Preview { let statuses: [DeliveryStatus] = [ .sending, .sent, .delivered(to: "John Doe", at: Date()), .read(by: "Jane Doe", at: Date()), .failed(reason: "Offline"), .partiallyDelivered(reached: 2, total: 5) ] List { ForEach(statuses, id: \.self) { status in HStack { Text(status.displayText) Spacer() DeliveryStatusView(status: status) } } } .environment(\.colorScheme, .light) List { ForEach(statuses, id: \.self) { status in HStack { Text(status.displayText) Spacer() DeliveryStatusView(status: status) } } } .environment(\.colorScheme, .dark) } ================================================ FILE: bitchat/Views/Components/PaymentChipView.swift ================================================ // // PaymentChipView.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import SwiftUI struct PaymentChipView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.openURL) private var openURL enum PaymentType { case cashu(String) case lightning(String) private static let cashuAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) private static func cashuURL(from link: String) -> URL? { if let url = URL(string: link), url.scheme != nil { return url } let enc = link.addingPercentEncoding(withAllowedCharacters: cashuAllowedCharacters) ?? link return URL(string: "cashu:\(enc)") } var url: URL? { switch self { case .cashu(let link): return Self.cashuURL(from: link) case .lightning(let link): return URL(string: link) } } var emoji: String { switch self { case .cashu: "🥜" case .lightning: "⚡" } } var label: String { switch self { case .cashu: String(localized: "content.payment.cashu", comment: "Label for Cashu payment chip") case .lightning: String(localized: "content.payment.lightning", comment: "Label for Lightning payment chip") } } } let paymentType: PaymentType private var fgColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } private var bgColor: Color { colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12) } private var border: Color { fgColor.opacity(0.25) } var body: some View { Button { #if os(iOS) if let url = paymentType.url { openURL(url) } #else if let url = paymentType.url { NSWorkspace.shared.open(url) } #endif } label: { HStack(spacing: 6) { Text(paymentType.emoji) Text(paymentType.label) .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) } .padding(.vertical, 6) .padding(.horizontal, 12) .background( RoundedRectangle(cornerRadius: 12) .fill(bgColor) ) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(border, lineWidth: 1) ) .foregroundColor(fgColor) } .buttonStyle(.plain) } } #Preview { let cashuLink = "https://example.com/cashu" let lightningLink = "https://example.com/lightning" List { HStack { PaymentChipView(paymentType: .cashu(cashuLink)) PaymentChipView(paymentType: .lightning(lightningLink)) } .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) .listRowBackground(EmptyView()) } .environment(\.colorScheme, .light) List { HStack { PaymentChipView(paymentType: .cashu(cashuLink)) PaymentChipView(paymentType: .lightning(lightningLink)) } .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) .listRowBackground(EmptyView()) } .environment(\.colorScheme, .dark) } ================================================ FILE: bitchat/Views/Components/TextMessageView.swift ================================================ // // TextMessageView.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import SwiftUI struct TextMessageView: View { @Environment(\.colorScheme) private var colorScheme: ColorScheme @EnvironmentObject private var viewModel: ChatViewModel let message: BitchatMessage @Binding var expandedMessageIDs: Set var body: some View { VStack(alignment: .leading, spacing: 0) { // Precompute heavy token scans once per row let cashuLinks = message.content.extractCashuLinks() let lightningLinks = message.content.extractLightningLinks() HStack(alignment: .top, spacing: 0) { let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty let isExpanded = expandedMessageIDs.contains(message.id) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) .fixedSize(horizontal: false, vertical: true) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .frame(maxWidth: .infinity, alignment: .leading) // Delivery status indicator for private messages if message.isPrivate && message.sender == viewModel.nickname, let status = message.deliveryStatus { DeliveryStatusView(status: status) .padding(.leading, 4) } } // Expand/Collapse for very long messages if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty { let isExpanded = expandedMessageIDs.contains(message.id) let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more") Button(labelKey) { if isExpanded { expandedMessageIDs.remove(message.id) } else { expandedMessageIDs.insert(message.id) } } .font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced)) .foregroundColor(Color.blue) .padding(.top, 4) } // Render payment chips (Lightning / Cashu) with rounded background if !lightningLinks.isEmpty || !cashuLinks.isEmpty { HStack(spacing: 8) { ForEach(lightningLinks, id: \.self) { link in PaymentChipView(paymentType: .lightning(link)) } ForEach(cashuLinks, id: \.self) { link in PaymentChipView(paymentType: .cashu(link)) } } .padding(.top, 6) .padding(.leading, 2) } } } } @available(macOS 14, iOS 17, *) #Preview { @Previewable @State var ids: Set = [] let keychain = PreviewKeychainManager() Group { List { TextMessageView(message: .preview, expandedMessageIDs: $ids) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) .listRowBackground(EmptyView()) } .environment(\.colorScheme, .light) List { TextMessageView(message: .preview, expandedMessageIDs: $ids) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) .listRowBackground(EmptyView()) } .environment(\.colorScheme, .dark) } .environmentObject( ChatViewModel( keychain: keychain, idBridge: NostrIdentityBridge(), identityManager: SecureIdentityStateManager(keychain) ) ) } ================================================ FILE: bitchat/Views/ContentView.swift ================================================ // // ContentView.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import SwiftUI #if os(iOS) import UIKit #endif #if os(macOS) import AppKit #endif import UniformTypeIdentifiers import BitLogger // MARK: - Supporting Types // // private struct MessageDisplayItem: Identifiable { let id: String let message: BitchatMessage } /// On macOS 14+, disables the default system focus ring on TextFields. /// On earlier macOS versions and on iOS this is a no-op. private struct FocusEffectDisabledModifier: ViewModifier { func body(content: Content) -> some View { #if os(macOS) if #available(macOS 14.0, *) { content.focusEffectDisabled() } else { content } #else content #endif } } // MARK: - Main Content View struct ContentView: View { // MARK: - Properties @EnvironmentObject var viewModel: ChatViewModel @ObservedObject private var locationManager = LocationChannelManager.shared @ObservedObject private var bookmarks = GeohashBookmarksStore.shared @State private var messageText = "" @FocusState private var isTextFieldFocused: Bool @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) private var dismiss @Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var showSidebar = false @State private var showAppInfo = false @State private var showMessageActions = false @State private var selectedMessageSender: String? @State private var selectedMessageSenderID: PeerID? @FocusState private var isNicknameFieldFocused: Bool @State private var isAtBottomPublic: Bool = true @State private var isAtBottomPrivate: Bool = true @State private var lastScrollTime: Date = .distantPast @State private var scrollThrottleTimer: Timer? @State private var autocompleteDebounceTimer: Timer? @State private var showLocationChannelsSheet = false @State private var showVerifySheet = false @State private var expandedMessageIDs: Set = [] @State private var showLocationNotes = false @State private var notesGeohash: String? = nil @State private var imagePreviewURL: URL? = nil @State private var recordingAlertMessage: String = "" @State private var showRecordingAlert = false @State private var isRecordingVoiceNote = false @State private var isPreparingVoiceNote = false @State private var recordingDuration: TimeInterval = 0 @State private var recordingTimer: Timer? @State private var recordingStartDate: Date? #if os(iOS) @State private var showImagePicker = false @State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera #else @State private var showMacImagePicker = false #endif @ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44 @ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11 @ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12 // Timer-based refresh removed; use LocationChannelManager live updates instead // Window sizes for rendering (infinite scroll up) @State private var windowCountPublic: Int = 300 @State private var windowCountPrivate: [PeerID: Int] = [:] // MARK: - Computed Properties private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white } private var textColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } private var secondaryTextColor: Color { colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) } private var headerLineLimit: Int? { dynamicTypeSize.isAccessibilitySize ? 2 : 1 } private var peopleSheetTitle: String { String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased() } private var peopleSheetSubtitle: String? { switch locationManager.selectedChannel { case .mesh: return "#mesh" case .location(let channel): return "#\(channel.geohash.lowercased())" } } private var peopleSheetActiveCount: Int { switch locationManager.selectedChannel { case .mesh: return viewModel.allPeers.filter { $0.peerID != viewModel.meshService.myPeerID }.count case .location: return viewModel.visibleGeohashPeople().count } } private struct PrivateHeaderContext { let headerPeerID: PeerID let peer: BitchatPeer? let displayName: String let isNostrAvailable: Bool } // MARK: - Body var body: some View { VStack(spacing: 0) { mainHeaderView .onAppear { viewModel.currentColorScheme = colorScheme #if os(macOS) // Focus message input on macOS launch, not nickname field DispatchQueue.main.async { isNicknameFieldFocused = false isTextFieldFocused = true } #endif } .onChange(of: colorScheme) { newValue in viewModel.currentColorScheme = newValue } Divider() GeometryReader { geometry in VStack(spacing: 0) { messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic) .background(backgroundColor) .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(width: geometry.size.width, height: geometry.size.height) } Divider() if viewModel.selectedPrivateChatPeer == nil { inputView } } .background(backgroundColor) .foregroundColor(textColor) #if os(macOS) .frame(minWidth: 600, minHeight: 400) #endif .onChange(of: viewModel.selectedPrivateChatPeer) { newValue in if newValue != nil { showSidebar = true } } .sheet( isPresented: Binding( get: { showSidebar || viewModel.selectedPrivateChatPeer != nil }, set: { isPresented in if !isPresented { showSidebar = false viewModel.endPrivateChat() } } ) ) { peopleSheetView } .sheet(isPresented: $showAppInfo) { AppInfoView() .environmentObject(viewModel) .onAppear { viewModel.isAppInfoPresented = true } .onDisappear { viewModel.isAppInfoPresented = false } } .sheet(isPresented: Binding( get: { viewModel.showingFingerprintFor != nil && !showSidebar && viewModel.selectedPrivateChatPeer == nil }, set: { _ in viewModel.showingFingerprintFor = nil } )) { if let peerID = viewModel.showingFingerprintFor { FingerprintView(viewModel: viewModel, peerID: peerID) .environmentObject(viewModel) } } #if os(iOS) // Only present image picker from main view when NOT in a sheet .fullScreenCover(isPresented: Binding( get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil }, set: { newValue in if !newValue { showImagePicker = false } } )) { ImagePickerView(sourceType: imagePickerSourceType) { image in showImagePicker = false if let image = image { Task { do { let processedURL = try ImageUtils.processImage(image) await MainActor.run { viewModel.sendImage(from: processedURL) } } catch { SecureLogger.error("Image processing failed: \(error)", category: .session) } } } } .environmentObject(viewModel) .ignoresSafeArea() } #endif #if os(macOS) // Only present Mac image picker from main view when NOT in a sheet .sheet(isPresented: Binding( get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil }, set: { newValue in if !newValue { showMacImagePicker = false } } )) { MacImagePickerView { url in showMacImagePicker = false if let url = url { Task { do { let processedURL = try ImageUtils.processImage(at: url) await MainActor.run { viewModel.sendImage(from: processedURL) } } catch { SecureLogger.error("Image processing failed: \(error)", category: .session) } } } } .environmentObject(viewModel) } #endif .sheet(isPresented: Binding( get: { imagePreviewURL != nil }, set: { presenting in if !presenting { imagePreviewURL = nil } } )) { if let url = imagePreviewURL { ImagePreviewView(url: url) .environmentObject(viewModel) } } .alert("Recording Error", isPresented: $showRecordingAlert, actions: { Button("OK", role: .cancel) {} }, message: { Text(recordingAlertMessage) }) .confirmationDialog( selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"), isPresented: $showMessageActions, titleVisibility: .visible ) { Button("content.actions.mention") { if let sender = selectedMessageSender { // Pre-fill the input with an @mention and focus the field messageText = "@\(sender) " isTextFieldFocused = true } } Button("content.actions.direct_message") { if let peerID = selectedMessageSenderID { if peerID.isGeoChat { if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) { viewModel.startGeohashDM(withPubkeyHex: full) } } else { viewModel.startPrivateChat(with: peerID) } withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showSidebar = true } } } Button("content.actions.hug") { if let sender = selectedMessageSender { viewModel.sendMessage("/hug @\(sender)") } } Button("content.actions.slap") { if let sender = selectedMessageSender { viewModel.sendMessage("/slap @\(sender)") } } Button("content.actions.block", role: .destructive) { // Prefer direct geohash block when we have a Nostr sender ID if let peerID = selectedMessageSenderID, peerID.isGeoChat, let full = viewModel.fullNostrHex(forSenderPeerID: peerID), let sender = selectedMessageSender { viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender) } else if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") } } Button("common.cancel", role: .cancel) {} } .alert("content.alert.bluetooth_required.title", isPresented: $viewModel.showBluetoothAlert) { Button("content.alert.bluetooth_required.settings") { #if os(iOS) if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } #endif } Button("common.ok", role: .cancel) {} } message: { Text(viewModel.bluetoothAlertMessage) } .onDisappear { // Clean up timers scrollThrottleTimer?.invalidate() autocompleteDebounceTimer?.invalidate() } } // MARK: - Message List View private func messagesView(privatePeer: PeerID?, isAtBottom: Binding) -> some View { let messages: [BitchatMessage] = { if let peerID = privatePeer { return viewModel.getPrivateChatMessages(for: peerID) } return viewModel.messages }() let currentWindowCount: Int = { if let peer = privatePeer { return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate } return windowCountPublic }() let windowedMessages: [BitchatMessage] = Array(messages.suffix(currentWindowCount)) let contextKey: String = { if let peer = privatePeer { return "dm:\(peer)" } switch locationManager.selectedChannel { case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)" } }() let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message) } return ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 0) { ForEach(messageItems) { item in let message = item.message messageRow(for: message) .onAppear { if message.id == windowedMessages.last?.id { isAtBottom.wrappedValue = true } if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count { expandWindow( ifNeededFor: message, allMessages: messages, privatePeer: privatePeer, proxy: proxy ) } } .onDisappear { if message.id == windowedMessages.last?.id { isAtBottom.wrappedValue = false } } .contentShape(Rectangle()) .onTapGesture { if message.sender != "system" { messageText = "@\(message.sender) " isTextFieldFocused = true } } .contextMenu { Button("content.message.copy") { #if os(iOS) UIPasteboard.general.string = message.content #else let pb = NSPasteboard.general pb.clearContents() pb.setString(message.content, forType: .string) #endif } } .padding(.horizontal, 12) .padding(.vertical, 1) } } .transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } } .padding(.vertical, 2) } .background(backgroundColor) .onOpenURL { handleOpenURL($0) } .onTapGesture(count: 3) { viewModel.sendMessage("/clear") } .onAppear { scrollToBottom(on: proxy, privatePeer: privatePeer, isAtBottom: isAtBottom) } .onChange(of: privatePeer) { _ in scrollToBottom(on: proxy, privatePeer: privatePeer, isAtBottom: isAtBottom) } .onChange(of: viewModel.messages.count) { _ in if privatePeer == nil && !viewModel.messages.isEmpty { // If the newest message is from me, always scroll to bottom let lastMsg = viewModel.messages.last! let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#") if !isFromSelf { // Only autoscroll when user is at/near bottom guard isAtBottom.wrappedValue else { return } } else { // Ensure we consider ourselves at bottom for subsequent messages isAtBottom.wrappedValue = true } // Throttle scroll animations to prevent excessive UI updates let now = Date() if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds { // Immediate scroll if enough time has passed lastScrollTime = now let contextKey: String = { switch locationManager.selectedChannel { case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)" } }() let count = windowCountPublic let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } DispatchQueue.main.async { if let target = target { proxy.scrollTo(target, anchor: .bottom) } } } else { // Schedule a delayed scroll scrollThrottleTimer?.invalidate() scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { [weak viewModel] _ in Task { @MainActor in lastScrollTime = Date() let contextKey: String = { switch locationManager.selectedChannel { case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)" } }() let count = windowCountPublic let target = viewModel?.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } if let target = target { proxy.scrollTo(target, anchor: .bottom) } } } } } } .onChange(of: viewModel.privateChats) { _ in if let peerID = privatePeer, let messages = viewModel.privateChats[peerID], !messages.isEmpty { // If the newest private message is from me, always scroll let lastMsg = messages.last! let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#") if !isFromSelf { // Only autoscroll when user is at/near bottom guard isAtBottom.wrappedValue else { return } } else { isAtBottom.wrappedValue = true } // Same throttling for private chats let now = Date() if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds { lastScrollTime = now let contextKey = "dm:\(peerID)" let count = windowCountPrivate[peerID] ?? 300 let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } DispatchQueue.main.async { if let target = target { proxy.scrollTo(target, anchor: .bottom) } } } else { scrollThrottleTimer?.invalidate() scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in lastScrollTime = Date() let contextKey = "dm:\(peerID)" let count = windowCountPrivate[peerID] ?? 300 let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } DispatchQueue.main.async { if let target = target { proxy.scrollTo(target, anchor: .bottom) } } } } } } .onChange(of: locationManager.selectedChannel) { newChannel in // When switching to a new geohash channel, scroll to the bottom guard privatePeer == nil else { return } switch newChannel { case .mesh: break case .location(let ch): // Reset window size windowCountPublic = TransportConfig.uiWindowInitialCountPublic let contextKey = "geo:\(ch.geohash)" let last = viewModel.messages.suffix(windowCountPublic).last?.id let target = last.map { "\(contextKey)|\($0)" } isAtBottom.wrappedValue = true DispatchQueue.main.async { if let target = target { proxy.scrollTo(target, anchor: .bottom) } } } } .onAppear { // Also check when view appears if let peerID = privatePeer { // Try multiple times to ensure read receipts are sent viewModel.markPrivateMessagesAsRead(from: peerID) DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { viewModel.markPrivateMessagesAsRead(from: peerID) } DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) { viewModel.markPrivateMessagesAsRead(from: peerID) } } } } .environment(\.openURL, OpenURLAction { url in // Intercept custom cashu: links created in attributed text if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" { #if os(iOS) UIApplication.shared.open(url) return .handled #else // On non-iOS platforms, let the system handle or ignore return .systemAction #endif } return .systemAction }) } // MARK: - Input View @ViewBuilder private var inputView: some View { VStack(alignment: .leading, spacing: 6) { // @mentions autocomplete if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty { VStack(alignment: .leading, spacing: 0) { ForEach(Array(viewModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in Button(action: { _ = viewModel.completeNickname(suggestion, in: &messageText) }) { HStack { Text(suggestion) .font(.bitchatSystem(size: 11, design: .monospaced)) .foregroundColor(textColor) .fontWeight(.medium) Spacer() } .padding(.horizontal, 12) .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) .background(Color.gray.opacity(0.1)) } } .background(backgroundColor) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1) ) .padding(.horizontal, 12) } CommandSuggestionsView( messageText: $messageText, textColor: textColor, backgroundColor: backgroundColor, secondaryTextColor: secondaryTextColor ) // Recording indicator if isPreparingVoiceNote || isRecordingVoiceNote { recordingIndicator } HStack(alignment: .center, spacing: 4) { TextField( "", text: $messageText, prompt: Text( String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer") ) .foregroundColor(secondaryTextColor.opacity(0.6)) ) .textFieldStyle(.plain) .font(.bitchatSystem(size: 15, design: .monospaced)) .foregroundColor(textColor) .focused($isTextFieldFocused) .autocorrectionDisabled(true) #if os(iOS) .textInputAutocapitalization(.sentences) #endif .submitLabel(.send) .onSubmit { sendMessage() } .padding(.vertical, 4) .padding(.horizontal, 6) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7)) ) .modifier(FocusEffectDisabledModifier()) .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: messageText) { newValue in autocompleteDebounceTimer?.invalidate() autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak viewModel] _ in let cursorPosition = newValue.count Task { @MainActor in viewModel?.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) } } } HStack(alignment: .center, spacing: 4) { if shouldShowMediaControls { attachmentButton } sendOrMicButton } } } .padding(.horizontal, 6) .padding(.top, 6) .padding(.bottom, 8) .background(backgroundColor.opacity(0.95)) } private func handleOpenURL(_ url: URL) { guard url.scheme == "bitchat" else { return } switch url.host { case "user": let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let peerID = PeerID(str: id.removingPercentEncoding ?? id) selectedMessageSenderID = peerID if peerID.isGeoDM || peerID.isGeoChat { selectedMessageSender = viewModel.geohashDisplayName(for: peerID) } else if let name = viewModel.meshService.peerNickname(peerID: peerID) { selectedMessageSender = name } else { selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender } if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) { selectedMessageSender = nil selectedMessageSenderID = nil } else { showMessageActions = true } case "geohash": let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return } func levelForLength(_ len: Int) -> GeohashChannelLevel { switch len { case 0...2: return .region case 3...4: return .province case 5: return .city case 6: return .neighborhood case 7: return .block default: return .block } } let level = levelForLength(gh.count) let channel = GeohashChannel(level: level, geohash: gh) let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh } if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty { LocationChannelManager.shared.markTeleported(for: gh, true) } LocationChannelManager.shared.select(ChannelID.location(channel)) default: return } } private func scrollToBottom(on proxy: ScrollViewProxy, privatePeer: PeerID?, isAtBottom: Binding) { let targetID: String? = { if let peer = privatePeer, let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { return "dm:\(peer)|\(last)" } let contextKey: String = { switch locationManager.selectedChannel { case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)" } }() if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } return nil }() isAtBottom.wrappedValue = true DispatchQueue.main.async { if let targetID { proxy.scrollTo(targetID, anchor: .bottom) } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { let secondTarget: String? = { if let peer = privatePeer, let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { return "dm:\(peer)|\(last)" } let contextKey: String = { switch locationManager.selectedChannel { case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)" } }() if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } return nil }() if let secondTarget { proxy.scrollTo(secondTarget, anchor: .bottom) } } } // MARK: - Actions private func sendMessage() { let trimmed = trimmedMessageText guard !trimmed.isEmpty else { return } // Clear input immediately for instant feedback messageText = "" // Defer actual send to next runloop to avoid blocking DispatchQueue.main.async { self.viewModel.sendMessage(trimmed) } } // MARK: - Sheet Content private var peopleSheetView: some View { NavigationStack { Group { if viewModel.selectedPrivateChatPeer != nil { privateChatSheetView } else { peopleListSheetView } } .navigationDestination(isPresented: Binding( get: { viewModel.showingFingerprintFor != nil && (showSidebar || viewModel.selectedPrivateChatPeer != nil) }, set: { isPresented in if !isPresented { viewModel.showingFingerprintFor = nil } } )) { if let peerID = viewModel.showingFingerprintFor { FingerprintView(viewModel: viewModel, peerID: peerID) .environmentObject(viewModel) } } } .background(backgroundColor) .foregroundColor(textColor) #if os(macOS) .frame(minWidth: 420, minHeight: 520) #endif // Present image picker from sheet context when IN a sheet (parent-child pattern) #if os(iOS) .fullScreenCover(isPresented: Binding( get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) }, set: { newValue in if !newValue { showImagePicker = false } } )) { ImagePickerView(sourceType: imagePickerSourceType) { image in showImagePicker = false if let image = image { Task { do { let processedURL = try ImageUtils.processImage(image) await MainActor.run { viewModel.sendImage(from: processedURL) } } catch { SecureLogger.error("Image processing failed: \(error)", category: .session) } } } } .environmentObject(viewModel) .ignoresSafeArea() } #endif #if os(macOS) .sheet(isPresented: $showMacImagePicker) { MacImagePickerView { url in showMacImagePicker = false if let url = url { Task { do { let processedURL = try ImageUtils.processImage(at: url) await MainActor.run { viewModel.sendImage(from: processedURL) } } catch { SecureLogger.error("Image processing failed: \(error)", category: .session) } } } } .environmentObject(viewModel) } #endif } // MARK: - People Sheet Views private var peopleListSheetView: some View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 12) { Text(peopleSheetTitle) .font(.bitchatSystem(size: 18, design: .monospaced)) .foregroundColor(textColor) Spacer() if case .mesh = locationManager.selectedChannel { Button(action: { showVerifySheet = true }) { Image(systemName: "qrcode") .font(.bitchatSystem(size: 14)) } .buttonStyle(.plain) .help( String(localized: "content.help.verification", comment: "Help text for verification button") ) } Button(action: { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { dismiss() showSidebar = false showVerifySheet = false viewModel.endPrivateChat() } }) { Image(systemName: "xmark") .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) .frame(width: 32, height: 32) } .buttonStyle(.plain) .accessibilityLabel("Close") } let activeText = String.localizedStringWithFormat( String(localized: "%@ active", comment: "Count of active users in the people sheet"), "\(peopleSheetActiveCount)" ) if let subtitle = peopleSheetSubtitle { let subtitleColor: Color = { switch locationManager.selectedChannel { case .mesh: return Color.blue case .location: return Color.green } }() HStack(spacing: 6) { Text(subtitle) .foregroundColor(subtitleColor) Text(activeText) .foregroundColor(.secondary) } .font(.bitchatSystem(size: 12, design: .monospaced)) } else { Text(activeText) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.secondary) } } .padding(.horizontal, 16) .padding(.top, 16) .padding(.bottom, 12) .background(backgroundColor) ScrollView { VStack(alignment: .leading, spacing: 6) { if case .location = locationManager.selectedChannel { GeohashPeopleList( viewModel: viewModel, textColor: textColor, secondaryTextColor: secondaryTextColor, onTapPerson: { showSidebar = true } ) } else { MeshPeerList( viewModel: viewModel, textColor: textColor, secondaryTextColor: secondaryTextColor, onTapPeer: { peerID in viewModel.startPrivateChat(with: peerID) showSidebar = true }, onToggleFavorite: { peerID in viewModel.toggleFavorite(peerID: peerID) }, onShowFingerprint: { peerID in viewModel.showFingerprint(for: peerID) } ) } } .padding(.top, 4) .id(viewModel.allPeers.map { "\($0.peerID)-\($0.isConnected)" }.joined()) } } } // MARK: - View Components private var privateChatSheetView: some View { VStack(spacing: 0) { if let privatePeerID = viewModel.selectedPrivateChatPeer { let headerContext = makePrivateHeaderContext(for: privatePeerID) HStack(spacing: 12) { Button(action: { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { viewModel.endPrivateChat() } }) { Image(systemName: "chevron.left") .font(.bitchatSystem(size: 12)) .foregroundColor(textColor) .frame(width: 44, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel( String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat") ) Spacer(minLength: 0) HStack(spacing: 8) { privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID) let isFavorite = viewModel.isFavorite(peerID: headerContext.headerPeerID) if !privatePeerID.isGeoDM { Button(action: { viewModel.toggleFavorite(peerID: headerContext.headerPeerID) }) { Image(systemName: isFavorite ? "star.fill" : "star") .font(.bitchatSystem(size: 14)) .foregroundColor(isFavorite ? Color.yellow : textColor) } .buttonStyle(.plain) .accessibilityLabel( isFavorite ? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite") : String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") ) } } .frame(maxWidth: .infinity) Spacer(minLength: 0) Button(action: { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { viewModel.endPrivateChat() showSidebar = true } }) { Image(systemName: "xmark") .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) .frame(width: 32, height: 32) } .buttonStyle(.plain) .accessibilityLabel("Close") } .frame(height: headerHeight) .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 12) .background(backgroundColor) } messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate) .background(backgroundColor) .frame(maxWidth: .infinity, maxHeight: .infinity) Divider() inputView } .background(backgroundColor) .foregroundColor(textColor) .highPriorityGesture( DragGesture(minimumDistance: 25, coordinateSpace: .local) .onEnded { value in let horizontal = value.translation.width let vertical = abs(value.translation.height) guard horizontal > 80, vertical < 60 else { return } withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showSidebar = true viewModel.endPrivateChat() } } ) } private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: PeerID) -> some View { Button(action: { viewModel.showFingerprint(for: context.headerPeerID) }) { HStack(spacing: 6) { if let connectionState = context.peer?.connectionState { switch connectionState { case .bluetoothConnected: Image(systemName: "dot.radiowaves.left.and.right") .font(.bitchatSystem(size: 14)) .foregroundColor(textColor) .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) case .meshReachable: Image(systemName: "point.3.filled.connected.trianglepath.dotted") .font(.bitchatSystem(size: 14)) .foregroundColor(textColor) .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) case .nostrAvailable: Image(systemName: "globe") .font(.bitchatSystem(size: 14)) .foregroundColor(.purple) .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) case .offline: EmptyView() } } else if viewModel.meshService.isPeerReachable(context.headerPeerID) { Image(systemName: "point.3.filled.connected.trianglepath.dotted") .font(.bitchatSystem(size: 14)) .foregroundColor(textColor) .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) } else if context.isNostrAvailable { Image(systemName: "globe") .font(.bitchatSystem(size: 14)) .foregroundColor(.purple) .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) } else if viewModel.meshService.isPeerConnected(context.headerPeerID) || viewModel.connectedPeers.contains(context.headerPeerID) { Image(systemName: "dot.radiowaves.left.and.right") .font(.bitchatSystem(size: 14)) .foregroundColor(textColor) .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) } Text(context.displayName) .font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced)) .foregroundColor(textColor) if !privatePeerID.isGeoDM { let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID) let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) if let icon = encryptionStatus.icon { Image(systemName: icon) .font(.bitchatSystem(size: 14)) .foregroundColor(encryptionStatus == .noiseVerified ? textColor : encryptionStatus == .noiseSecured ? textColor : Color.red) .accessibilityLabel( String( format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"), locale: .current, encryptionStatus.accessibilityDescription ) ) } } } } .buttonStyle(.plain) .accessibilityLabel( String( format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"), locale: .current, context.displayName ) ) .accessibilityHint( String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") ) .frame(height: headerHeight) } private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext { let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID) let peer = viewModel.getPeer(byID: headerPeerID) let displayName: String = { if privatePeerID.isGeoDM, case .location(let ch) = locationManager.selectedChannel { let disp = viewModel.geohashDisplayName(for: privatePeerID) return "#\(ch.geohash)/@\(disp)" } if let name = peer?.displayName { return name } if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name } if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID.id) ?? Data()), !fav.peerNickname.isEmpty { return fav.peerNickname } if headerPeerID.id.count == 16 { let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) if let id = candidates.first, let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) { if let pet = social.localPetname, !pet.isEmpty { return pet } if !social.claimedNickname.isEmpty { return social.claimedNickname } } } else if let keyData = headerPeerID.noiseKey { let fp = keyData.sha256Fingerprint() if let social = viewModel.identityManager.getSocialIdentity(for: fp) { if let pet = social.localPetname, !pet.isEmpty { return pet } if !social.claimedNickname.isEmpty { return social.claimedNickname } } } return String(localized: "common.unknown", comment: "Fallback label for unknown peer") }() let isNostrAvailable: Bool = { guard let connectionState = peer?.connectionState else { if let noiseKey = Data(hexString: headerPeerID.id), let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), favoriteStatus.isMutual { return true } return false } return connectionState == .nostrAvailable }() return PrivateHeaderContext( headerPeerID: headerPeerID, peer: peer, displayName: displayName, isNostrAvailable: isNostrAvailable ) } // Compute channel-aware people count and color for toolbar (cross-platform) private func channelPeopleCountAndColor() -> (Int, Color) { switch locationManager.selectedChannel { case .location: let n = viewModel.geohashPeople.count let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) return (n, n > 0 ? standardGreen : Color.secondary) case .mesh: let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in guard peer.peerID != viewModel.meshService.myPeerID else { return } if peer.isConnected { counts.mesh += 1; counts.others += 1 } else if peer.isReachable { counts.others += 1 } } let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary return (counts.others, color) } } private var mainHeaderView: some View { HStack(spacing: 0) { Text(verbatim: "bitchat/") .font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced)) .foregroundColor(textColor) .onTapGesture(count: 3) { // PANIC: Triple-tap to clear all data viewModel.panicClearAllData() } .onTapGesture(count: 1) { // Single tap for app info showAppInfo = true } HStack(spacing: 0) { Text(verbatim: "@") .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(secondaryTextColor) TextField("content.input.nickname_placeholder", text: $viewModel.nickname) .textFieldStyle(.plain) .font(.bitchatSystem(size: 14, design: .monospaced)) .frame(maxWidth: 80) .foregroundColor(textColor) .focused($isNicknameFieldFocused) .autocorrectionDisabled(true) #if os(iOS) .textInputAutocapitalization(.never) #endif .modifier(FocusEffectDisabledModifier()) .onChange(of: isNicknameFieldFocused) { isFocused in if !isFocused { // Only validate when losing focus viewModel.validateAndSaveNickname() } } .onSubmit { viewModel.validateAndSaveNickname() } } Spacer() // Channel badge + dynamic spacing + people counter // Precompute header count and color outside the ViewBuilder expressions let cc = channelPeopleCountAndColor() let headerCountColor: Color = cc.1 let headerOtherPeersCount: Int = { if case .location = locationManager.selectedChannel { return viewModel.visibleGeohashPeople().count } return cc.0 }() HStack(spacing: 10) { // Unread icon immediately to the left of the channel badge (independent from channel button) // Unread indicator (now shown on iOS and macOS) if viewModel.hasAnyUnreadMessages { Button(action: { viewModel.openMostRelevantPrivateChat() }) { Image(systemName: "envelope.fill") .font(.bitchatSystem(size: 12)) .foregroundColor(Color.orange) } .buttonStyle(.plain) .accessibilityLabel( String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button") ) } // Notes icon (mesh only and when location is authorized), to the left of #mesh if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized { Button(action: { // Kick a one-shot refresh and show the sheet immediately. LocationChannelManager.shared.enableLocationChannels() LocationChannelManager.shared.refreshChannels() // If we already have a block geohash, pass it; otherwise wait in the sheet. notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash showLocationNotes = true }) { HStack(alignment: .center, spacing: 4) { Image(systemName: "note.text") .font(.bitchatSystem(size: 12)) .foregroundColor(Color.orange.opacity(0.8)) .padding(.top, 1) } .fixedSize(horizontal: true, vertical: false) } .buttonStyle(.plain) .accessibilityLabel( String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button") ) } // Bookmark toggle (geochats): to the left of #geohash if case .location(let ch) = locationManager.selectedChannel { Button(action: { bookmarks.toggle(ch.geohash) }) { Image(systemName: bookmarks.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark") .font(.bitchatSystem(size: 12)) } .buttonStyle(.plain) .accessibilityLabel( String( format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"), locale: .current, ch.geohash ) ) } // Location channels button '#' Button(action: { showLocationChannelsSheet = true }) { let badgeText: String = { switch locationManager.selectedChannel { case .mesh: return "#mesh" case .location(let ch): return "#\(ch.geohash)" } }() let badgeColor: Color = { switch locationManager.selectedChannel { case .mesh: return Color(hue: 0.60, saturation: 0.85, brightness: 0.82) case .location: return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) } }() Text(badgeText) .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(badgeColor) .lineLimit(headerLineLimit) .fixedSize(horizontal: true, vertical: false) .layoutPriority(2) .accessibilityLabel( String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button") ) } .buttonStyle(.plain) .padding(.leading, 4) .padding(.trailing, 2) HStack(spacing: 4) { // People icon with count Image(systemName: "person.2.fill") .font(.system(size: headerPeerIconSize, weight: .regular)) .accessibilityLabel( String( format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"), locale: .current, headerOtherPeersCount ) ) Text("\(headerOtherPeersCount)") .font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced)) .accessibilityHidden(true) } .foregroundColor(headerCountColor) .padding(.leading, 2) .lineLimit(headerLineLimit) .fixedSize(horizontal: true, vertical: false) // QR moved to the PEOPLE header in the sidebar when on mesh channel } .layoutPriority(3) .onTapGesture { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showSidebar.toggle() } } .sheet(isPresented: $showVerifySheet) { VerificationSheetView(isPresented: $showVerifySheet) .environmentObject(viewModel) } } .frame(height: headerHeight) .padding(.horizontal, 12) .sheet(isPresented: $showLocationChannelsSheet) { LocationChannelsSheet(isPresented: $showLocationChannelsSheet) .environmentObject(viewModel) .onAppear { viewModel.isLocationChannelsSheetPresented = true } .onDisappear { viewModel.isLocationChannelsSheetPresented = false } } .sheet(isPresented: $showLocationNotes, onDismiss: { notesGeohash = nil }) { Group { if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash { LocationNotesView(geohash: gh) .environmentObject(viewModel) } else { VStack(spacing: 12) { HStack { Text("content.notes.title") .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) Spacer() Button(action: { showLocationNotes = false }) { Image(systemName: "xmark") .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .foregroundColor(textColor) .frame(width: 32, height: 32) } .buttonStyle(.plain) .accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons")) } .frame(height: headerHeight) .padding(.horizontal, 12) .background(backgroundColor.opacity(0.95)) Text("content.notes.location_unavailable") .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(secondaryTextColor) Button("content.location.enable") { LocationChannelManager.shared.enableLocationChannels() LocationChannelManager.shared.refreshChannels() } .buttonStyle(.bordered) Spacer() } .background(backgroundColor) .foregroundColor(textColor) // per-sheet global onChange added below } } .onAppear { // Ensure we are authorized and start live location updates (distance-filtered) LocationChannelManager.shared.enableLocationChannels() LocationChannelManager.shared.beginLiveRefresh() } .onDisappear { LocationChannelManager.shared.endLiveRefresh() } .onChange(of: locationManager.availableChannels) { channels in if let current = channels.first(where: { $0.level == .building })?.geohash, notesGeohash != current { notesGeohash = current #if os(iOS) // Light taptic when geohash changes while the sheet is open let generator = UIImpactFeedbackGenerator(style: .light) generator.prepare() generator.impactOccurred() #endif } } } .onAppear { if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized, LocationChannelManager.shared.availableChannels.isEmpty { LocationChannelManager.shared.refreshChannels() } } .onChange(of: locationManager.selectedChannel) { _ in if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized, LocationChannelManager.shared.availableChannels.isEmpty { LocationChannelManager.shared.refreshChannels() } } .onChange(of: locationManager.permissionState) { _ in if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized, LocationChannelManager.shared.availableChannels.isEmpty { LocationChannelManager.shared.refreshChannels() } } .alert("content.alert.screenshot.title", isPresented: $viewModel.showScreenshotPrivacyWarning) { Button("common.ok", role: .cancel) {} } message: { Text("content.alert.screenshot.message") } .background(backgroundColor.opacity(0.95)) } } // MARK: - Helper Views // Rounded payment chip button // private enum MessageMedia { case voice(URL) case image(URL) var url: URL { switch self { case .voice(let url), .image(let url): return url } } } private extension ContentView { func mediaAttachment(for message: BitchatMessage) -> MessageMedia? { guard let baseDirectory = applicationFilesDirectory() else { return nil } // Extract filename from message content func url(from prefix: String, subdirectory: String) -> URL? { guard message.content.hasPrefix(prefix) else { return nil } let filename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) guard !filename.isEmpty else { return nil } // Construct URL directly without fileExists check (avoids blocking disk I/O in view body) // Files are checked during playback/display, so missing files fail gracefully let directory = baseDirectory.appendingPathComponent(subdirectory, isDirectory: true) return directory.appendingPathComponent(filename) } // Try outgoing first (most common for sent media), fall back to incoming if message.content.hasPrefix("[voice] ") { let filename = String(message.content.dropFirst("[voice] ".count)).trimmingCharacters(in: .whitespacesAndNewlines) guard !filename.isEmpty else { return nil } // Check outgoing first for sent messages, incoming for received let subdir = message.sender == viewModel.nickname ? "voicenotes/outgoing" : "voicenotes/incoming" let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename) return .voice(url) } if message.content.hasPrefix("[image] ") { let filename = String(message.content.dropFirst("[image] ".count)).trimmingCharacters(in: .whitespacesAndNewlines) guard !filename.isEmpty else { return nil } let subdir = message.sender == viewModel.nickname ? "images/outgoing" : "images/incoming" let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename) return .image(url) } return nil } func mediaSendState(for message: BitchatMessage, mediaURL: URL) -> (isSending: Bool, progress: Double?, canCancel: Bool) { var isSending = false var progress: Double? if let status = message.deliveryStatus { switch status { case .sending: isSending = true progress = 0 case .partiallyDelivered(let reached, let total): if total > 0 { isSending = true progress = Double(reached) / Double(total) } case .sent, .read, .delivered, .failed: break } } let isOutgoing = mediaURL.path.contains("/outgoing/") let canCancel = isSending && isOutgoing let clamped = progress.map { max(0, min(1, $0)) } return (isSending, isSending ? clamped : nil, canCancel) } @ViewBuilder private func messageRow(for message: BitchatMessage) -> some View { if message.sender == "system" { systemMessageRow(message) } else if let media = mediaAttachment(for: message) { mediaMessageRow(message: message, media: media) } else { TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs) } } @ViewBuilder private func systemMessageRow(_ message: BitchatMessage) -> some View { Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder private func mediaMessageRow(message: BitchatMessage, media: MessageMedia) -> some View { let mediaURL = media.url let state = mediaSendState(for: message, mediaURL: mediaURL) let isOutgoing = mediaURL.path.contains("/outgoing/") let isAuthoredByUs = isOutgoing || (message.senderPeerID == viewModel.meshService.myPeerID) let shouldBlurImage = !isAuthoredByUs let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center, spacing: 4) { Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) if message.isPrivate && message.sender == viewModel.nickname, let status = message.deliveryStatus { DeliveryStatusView(status: status) .padding(.leading, 4) } } Group { switch media { case .voice(let url): VoiceNoteView( url: url, isSending: state.isSending, sendProgress: state.progress, onCancel: cancelAction ) case .image(let url): BlockRevealImageView( url: url, revealProgress: state.progress, isSending: state.isSending, onCancel: cancelAction, initiallyBlurred: shouldBlurImage, onOpen: { if !state.isSending { imagePreviewURL = url } }, onDelete: shouldBlurImage ? { viewModel.deleteMediaMessage(messageID: message.id) } : nil ) .frame(maxWidth: 280) } } } .padding(.vertical, 4) } private func expandWindow(ifNeededFor message: BitchatMessage, allMessages: [BitchatMessage], privatePeer: PeerID?, proxy: ScrollViewProxy) { let step = TransportConfig.uiWindowStepCount let contextKey: String = { if let peer = privatePeer { return "dm:\(peer)" } switch locationManager.selectedChannel { case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)" } }() let preserveID = "\(contextKey)|\(message.id)" if let peer = privatePeer { let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate let newCount = min(allMessages.count, current + step) guard newCount != current else { return } windowCountPrivate[peer] = newCount DispatchQueue.main.async { proxy.scrollTo(preserveID, anchor: .top) } } else { let current = windowCountPublic let newCount = min(allMessages.count, current + step) guard newCount != current else { return } windowCountPublic = newCount DispatchQueue.main.async { proxy.scrollTo(preserveID, anchor: .top) } } } var recordingIndicator: some View { HStack(spacing: 12) { Image(systemName: "waveform.circle.fill") .foregroundColor(.red) .font(.bitchatSystem(size: 20)) Text("recording \(formattedRecordingDuration())", comment: "Voice note recording duration indicator") .font(.bitchatSystem(size: 13, design: .monospaced)) .foregroundColor(.red) Spacer() Button(action: cancelVoiceRecording) { Label("Cancel", systemImage: "xmark.circle") .labelStyle(.iconOnly) .font(.bitchatSystem(size: 18)) .foregroundColor(.red) } .buttonStyle(.plain) } .padding(10) .background( RoundedRectangle(cornerRadius: 12) .fill(Color.red.opacity(0.15)) ) } private var trimmedMessageText: String { messageText.trimmingCharacters(in: .whitespacesAndNewlines) } private var shouldShowMediaControls: Bool { if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) { return true } switch locationManager.selectedChannel { case .mesh: return true case .location: return false } } private var shouldShowVoiceControl: Bool { if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) { return true } switch locationManager.selectedChannel { case .mesh: return true case .location: return false } } private var composerAccentColor: Color { viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor } var attachmentButton: some View { #if os(iOS) Image(systemName: "camera.circle.fill") .font(.bitchatSystem(size: 24)) .foregroundColor(composerAccentColor) .frame(width: 36, height: 36) .contentShape(Circle()) .onTapGesture { // Tap = Photo Library imagePickerSourceType = .photoLibrary showImagePicker = true } .onLongPressGesture(minimumDuration: 0.3) { // Long press = Camera imagePickerSourceType = .camera showImagePicker = true } .accessibilityLabel("Tap for library, long press for camera") #else Button(action: { showMacImagePicker = true }) { Image(systemName: "photo.circle.fill") .font(.bitchatSystem(size: 24)) .foregroundColor(composerAccentColor) .frame(width: 36, height: 36) } .buttonStyle(.plain) .accessibilityLabel("Choose photo") #endif } @ViewBuilder var sendOrMicButton: some View { let hasText = !trimmedMessageText.isEmpty if shouldShowVoiceControl { ZStack { micButtonView .opacity(hasText ? 0 : 1) .allowsHitTesting(!hasText) sendButtonView(enabled: hasText) .opacity(hasText ? 1 : 0) .allowsHitTesting(hasText) } .frame(width: 36, height: 36) } else { sendButtonView(enabled: hasText) .frame(width: 36, height: 36) } } private var micButtonView: some View { let tint = (isRecordingVoiceNote || isPreparingVoiceNote) ? Color.red : composerAccentColor return Image(systemName: "mic.circle.fill") .font(.bitchatSystem(size: 24)) .foregroundColor(tint) .frame(width: 36, height: 36) .contentShape(Circle()) .overlay( Color.clear .contentShape(Circle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { _ in startVoiceRecording() } .onEnded { _ in finishVoiceRecording(send: true) } ) ) .accessibilityLabel("Hold to record a voice note") } private func sendButtonView(enabled: Bool) -> some View { let activeColor = composerAccentColor return Button(action: sendMessage) { Image(systemName: "arrow.up.circle.fill") .font(.bitchatSystem(size: 24)) .foregroundColor(enabled ? activeColor : Color.gray) .frame(width: 36, height: 36) } .buttonStyle(.plain) .disabled(!enabled) .accessibilityLabel( String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button") ) .accessibilityHint( enabled ? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message") : String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message") ) } func formattedRecordingDuration() -> String { let clamped = max(0, recordingDuration) let totalMilliseconds = Int((clamped * 1000).rounded()) let minutes = totalMilliseconds / 60_000 let seconds = (totalMilliseconds % 60_000) / 1_000 let centiseconds = (totalMilliseconds % 1_000) / 10 return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds) } func startVoiceRecording() { guard shouldShowVoiceControl else { return } guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return } isPreparingVoiceNote = true Task { @MainActor in let granted = await VoiceRecorder.shared.requestPermission() guard granted else { isPreparingVoiceNote = false recordingAlertMessage = "Microphone access is required to record voice notes." showRecordingAlert = true return } do { _ = try VoiceRecorder.shared.startRecording() recordingDuration = 0 recordingStartDate = Date() recordingTimer?.invalidate() recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in if let start = recordingStartDate { recordingDuration = Date().timeIntervalSince(start) } } if let timer = recordingTimer { RunLoop.main.add(timer, forMode: .common) } isPreparingVoiceNote = false isRecordingVoiceNote = true } catch { SecureLogger.error("Voice recording failed to start: \(error)", category: .session) recordingAlertMessage = "Could not start recording." showRecordingAlert = true VoiceRecorder.shared.cancelRecording() isPreparingVoiceNote = false isRecordingVoiceNote = false recordingStartDate = nil } } } func finishVoiceRecording(send: Bool) { if isPreparingVoiceNote { isPreparingVoiceNote = false VoiceRecorder.shared.cancelRecording() return } guard isRecordingVoiceNote else { return } isRecordingVoiceNote = false recordingTimer?.invalidate() recordingTimer = nil if let start = recordingStartDate { recordingDuration = Date().timeIntervalSince(start) } recordingStartDate = nil if send { let minimumDuration: TimeInterval = 1.0 VoiceRecorder.shared.stopRecording { url in DispatchQueue.main.async { guard let url = url, let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), let fileSize = attributes[.size] as? NSNumber, fileSize.intValue > 0, recordingDuration >= minimumDuration else { if let url = url { try? FileManager.default.removeItem(at: url) } recordingAlertMessage = recordingDuration < minimumDuration ? "Recording is too short." : "Recording failed to save." showRecordingAlert = true return } viewModel.sendVoiceNote(at: url) } } } else { VoiceRecorder.shared.cancelRecording() } } func cancelVoiceRecording() { if isPreparingVoiceNote || isRecordingVoiceNote { finishVoiceRecording(send: false) } } func handleImportResult(_ result: Result<[URL], Error>, handler: @escaping (URL) async -> Void) { switch result { case .success(let urls): guard let url = urls.first else { return } let needsStop = url.startAccessingSecurityScopedResource() Task { defer { if needsStop { url.stopAccessingSecurityScopedResource() } } await handler(url) } case .failure(let error): SecureLogger.error("Media import failed: \(error)", category: .session) } } func applicationFilesDirectory() -> URL? { // Cache the directory lookup to avoid repeated FileManager calls during view rendering struct Cache { static var cachedURL: URL? static var didAttempt = false } if Cache.didAttempt { return Cache.cachedURL } do { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesDir = base.appendingPathComponent("files", isDirectory: true) try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) Cache.cachedURL = filesDir Cache.didAttempt = true return filesDir } catch { SecureLogger.error("Failed to resolve application files directory: \(error)", category: .session) Cache.didAttempt = true return nil } } } // struct ImagePreviewView: View { let url: URL @Environment(\.dismiss) private var dismiss #if os(iOS) @State private var showExporter = false @State private var platformImage: UIImage? #else @State private var platformImage: NSImage? #endif var body: some View { ZStack { Color.black.ignoresSafeArea() VStack { Spacer() if let image = platformImage { #if os(iOS) Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fit) .padding() #else Image(nsImage: image) .resizable() .aspectRatio(contentMode: .fit) .padding() #endif } else { ProgressView() .progressViewStyle(.circular) .tint(.white) } Spacer() HStack { Button(action: { dismiss() }) { Text("close", comment: "Button to dismiss fullscreen media viewer") .font(.bitchatSystem(size: 15, weight: .semibold)) .foregroundColor(.white) .padding(.horizontal, 16) .padding(.vertical, 8) .background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1)) } Spacer() Button(action: saveCopy) { Text("save", comment: "Button to save media to device") .font(.bitchatSystem(size: 15, weight: .semibold)) .foregroundColor(.white) .padding(.horizontal, 16) .padding(.vertical, 8) .background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6))) } } .padding([.horizontal, .bottom], 24) } } .onAppear(perform: loadImage) #if os(iOS) .sheet(isPresented: $showExporter) { FileExportWrapper(url: url) } #endif } private func loadImage() { DispatchQueue.global(qos: .userInitiated).async { #if os(iOS) guard let image = UIImage(contentsOfFile: url.path) else { return } #else guard let image = NSImage(contentsOf: url) else { return } #endif DispatchQueue.main.async { self.platformImage = image } } } private func saveCopy() { #if os(iOS) showExporter = true #else Task { @MainActor in let panel = NSSavePanel() panel.canCreateDirectories = true panel.nameFieldStringValue = url.lastPathComponent panel.prompt = "save" if panel.runModal() == .OK, let destination = panel.url { do { if FileManager.default.fileExists(atPath: destination.path) { try FileManager.default.removeItem(at: destination) } try FileManager.default.copyItem(at: url, to: destination) } catch { SecureLogger.error("Failed to save image preview copy: \(error)", category: .session) } } } #endif } #if os(iOS) private struct FileExportWrapper: UIViewControllerRepresentable { let url: URL func makeUIViewController(context: Context) -> UIDocumentPickerViewController { let controller = UIDocumentPickerViewController(forExporting: [url]) controller.shouldShowFileExtensions = true return controller } func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} } #endif } #if os(iOS) // MARK: - Image Picker (Camera or Photo Library) struct ImagePickerView: UIViewControllerRepresentable { let sourceType: UIImagePickerController.SourceType let completion: (UIImage?) -> Void func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.sourceType = sourceType picker.delegate = context.coordinator picker.allowsEditing = false // Use standard full screen - iOS handles safe areas automatically picker.modalPresentationStyle = .fullScreen // Force dark mode to make safe area bars black instead of white picker.overrideUserInterfaceStyle = .dark return picker } func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(completion: completion) } class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { let completion: (UIImage?) -> Void init(completion: @escaping (UIImage?) -> Void) { self.completion = completion } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { let image = info[.originalImage] as? UIImage completion(image) } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { completion(nil) } } } #endif #if os(macOS) // MARK: - macOS Image Picker struct MacImagePickerView: View { let completion: (URL?) -> Void @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 16) { Text("Choose an image") .font(.headline) Button("Select Image") { let panel = NSOpenPanel() panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.canChooseFiles = true panel.allowedContentTypes = [.image, .png, .jpeg, .heic] panel.message = "Choose an image to send" if panel.runModal() == .OK { completion(panel.url) } else { dismiss() } } .buttonStyle(.borderedProminent) Button("Cancel") { completion(nil) } .buttonStyle(.bordered) } .padding(40) .frame(minWidth: 300, minHeight: 150) } } #endif ================================================ FILE: bitchat/Views/FingerprintView.swift ================================================ // // FingerprintView.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import SwiftUI struct FingerprintView: View { @ObservedObject var viewModel: ChatViewModel let peerID: PeerID @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme private var textColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white } private enum Strings { static let title: LocalizedStringKey = "fingerprint.title" static let theirFingerprint: LocalizedStringKey = "fingerprint.their_label" static let handshakePending: LocalizedStringKey = "fingerprint.handshake_pending" static let yourFingerprint: LocalizedStringKey = "fingerprint.your_label" static let copy: LocalizedStringKey = "common.copy" static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified" static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified" static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified" static func verifyHint(_ nickname: String) -> String { String( format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"), locale: .current, nickname ) } static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified" static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification" static func unknownPeer() -> String { String(localized: "common.unknown", comment: "Label for an unknown peer") } } var body: some View { VStack(spacing: 20) { // Header HStack { Text(Strings.title) .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .foregroundColor(textColor) Spacer() Button(action: { dismiss() }) { Image(systemName: "xmark") .font(.bitchatSystem(size: 14, weight: .semibold)) } .foregroundColor(textColor) } .padding() VStack(alignment: .leading, spacing: 16) { // Prefer short mesh ID for session/encryption status let statusPeerID = viewModel.getShortIDForNoiseKey(peerID) // Resolve a friendly name let peerNickname: String = { if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName } if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name } if let data = peerID.noiseKey { if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname } let fp = data.sha256Fingerprint() if let social = viewModel.identityManager.getSocialIdentity(for: fp) { if let pet = social.localPetname, !pet.isEmpty { return pet } if !social.claimedNickname.isEmpty { return social.claimedNickname } } } return Strings.unknownPeer() }() // Accurate encryption state based on short ID session let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) HStack { if let icon = encryptionStatus.icon { Image(systemName: icon) .font(.bitchatSystem(size: 20)) .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor) } VStack(alignment: .leading, spacing: 4) { Text(peerNickname) .font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced)) .foregroundColor(textColor) Text(encryptionStatus.description) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(textColor.opacity(0.7)) } Spacer() } .padding() .background(Color.gray.opacity(0.1)) .cornerRadius(8) // Their fingerprint VStack(alignment: .leading, spacing: 8) { Text(Strings.theirFingerprint) .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) .foregroundColor(textColor.opacity(0.7)) if let fingerprint = viewModel.getFingerprint(for: statusPeerID) { Text(formatFingerprint(fingerprint)) .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(textColor) .multilineTextAlignment(.leading) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) .padding() .frame(maxWidth: .infinity) .background(Color.gray.opacity(0.1)) .cornerRadius(8) .contextMenu { Button(Strings.copy) { #if os(iOS) UIPasteboard.general.string = fingerprint #else NSPasteboard.general.clearContents() NSPasteboard.general.setString(fingerprint, forType: .string) #endif } } } else { Text(Strings.handshakePending) .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(Color.orange) .padding() } } // My fingerprint VStack(alignment: .leading, spacing: 8) { Text(Strings.yourFingerprint) .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) .foregroundColor(textColor.opacity(0.7)) let myFingerprint = viewModel.getMyFingerprint() Text(formatFingerprint(myFingerprint)) .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(textColor) .multilineTextAlignment(.leading) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) .padding() .frame(maxWidth: .infinity) .background(Color.gray.opacity(0.1)) .cornerRadius(8) .contextMenu { Button(Strings.copy) { #if os(iOS) UIPasteboard.general.string = myFingerprint #else NSPasteboard.general.clearContents() NSPasteboard.general.setString(myFingerprint, forType: .string) #endif } } } // Verification status if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified { let isVerified = encryptionStatus == .noiseVerified VStack(spacing: 12) { Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge) .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .foregroundColor(isVerified ? Color.green : Color.orange) .frame(maxWidth: .infinity) Group { if isVerified { Text(Strings.verifiedMessage) } else { Text(Strings.verifyHint(peerNickname)) } } .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(textColor.opacity(0.7)) .multilineTextAlignment(.center) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) if !isVerified { Button(action: { viewModel.verifyFingerprint(for: peerID) dismiss() }) { Text(Strings.markVerified) .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .foregroundColor(.white) .padding(.horizontal, 20) .padding(.vertical, 10) .background(Color.green) .cornerRadius(8) } .buttonStyle(PlainButtonStyle()) } else { Button(action: { viewModel.unverifyFingerprint(for: peerID) dismiss() }) { Text(Strings.removeVerification) .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .foregroundColor(.white) .padding(.horizontal, 20) .padding(.vertical, 10) .background(Color.red) .cornerRadius(8) } .buttonStyle(PlainButtonStyle()) } } .padding(.top) .frame(maxWidth: .infinity) } } .padding() .frame(maxWidth: 500) // Constrain max width for better readability Spacer() } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) .background(backgroundColor) } private func formatFingerprint(_ fingerprint: String) -> String { // Convert to uppercase and format into 4 lines (4 groups of 4 on each line) let uppercased = fingerprint.uppercased() var formatted = "" for (index, char) in uppercased.enumerated() { // Add space every 4 characters (but not at the start) if index > 0 && index % 4 == 0 { // Add newline after every 16 characters (4 groups of 4) if index % 16 == 0 { formatted += "\n" } else { formatted += " " } } formatted += String(char) } return formatted } } ================================================ FILE: bitchat/Views/GeohashPeopleList.swift ================================================ import SwiftUI struct GeohashPeopleList: View { @ObservedObject var viewModel: ChatViewModel let textColor: Color let secondaryTextColor: Color let onTapPerson: () -> Void @Environment(\.colorScheme) var colorScheme @State private var orderedIDs: [String] = [] private enum Strings { static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby" static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix" static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels") static let unblock: LocalizedStringKey = "geohash_people.action.unblock" static let block: LocalizedStringKey = "geohash_people.action.block" } var body: some View { if viewModel.visibleGeohashPeople().isEmpty { VStack(alignment: .leading, spacing: 0) { Text(Strings.noneNearby) .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(secondaryTextColor) .padding(.horizontal) .padding(.top, 12) } } else { let myHex: String? = { if case .location(let ch) = LocationChannelManager.shared.selectedChannel, let id = try? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) { return id.publicKeyHex.lowercased() } return nil }() let people = viewModel.visibleGeohashPeople() let currentIDs = people.map { $0.id } let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() }) let isTeleportedID: (String) -> Bool = { id in if teleportedSet.contains(id.lowercased()) { return true } if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true } return false } let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } let nonTele = displayIDs.filter { !isTeleportedID($0) } let tele = displayIDs.filter { isTeleportedID($0) } let finalOrder: [String] = nonTele + tele let firstID = finalOrder.first let personByID = Dictionary(uniqueKeysWithValues: people.map { ($0.id, $0) }) VStack(alignment: .leading, spacing: 0) { ForEach(finalOrder.filter { personByID[$0] != nil }, id: \.self) { pid in let person = personByID[pid]! HStack(spacing: 4) { let isMe = (person.id == myHex) let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported) let icon = teleported ? "face.dashed" : "mappin.and.ellipse" let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark) let rowColor: Color = isMe ? .orange : assignedColor Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor) let (base, suffix) = person.displayName.splitSuffix() HStack(spacing: 0) { Text(base) .font(.bitchatSystem(size: 14, design: .monospaced)) .fontWeight(isMe ? .bold : .regular) .foregroundColor(rowColor) if !suffix.isEmpty { let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) Text(suffix) .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(suffixColor) } if isMe { Text(Strings.youSuffix) .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(rowColor) } } if let me = myHex, person.id != me { if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) { Image(systemName: "nosign") .font(.bitchatSystem(size: 10)) .foregroundColor(.red) .help(Strings.blockedTooltip) } } Spacer() } .padding(.horizontal) .padding(.vertical, 4) .padding(.top, person.id == firstID ? 10 : 0) .contentShape(Rectangle()) .onTapGesture { if person.id != myHex { viewModel.startGeohashDM(withPubkeyHex: person.id) onTapPerson() } } .contextMenu { if let me = myHex, person.id == me { EmptyView() } else { let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) if blocked { Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) } } else { Button(Strings.block) { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) } } } } } } // Seed and update order outside result builder .onAppear { orderedIDs = currentIDs } .onChange(of: currentIDs) { ids in var newOrder = orderedIDs newOrder.removeAll { !ids.contains($0) } for id in ids where !newOrder.contains(id) { newOrder.append(id) } if newOrder != orderedIDs { orderedIDs = newOrder } } } } } ================================================ FILE: bitchat/Views/LocationChannelsSheet.swift ================================================ import SwiftUI import CoreLocation #if os(iOS) import UIKit #else import AppKit #endif struct LocationChannelsSheet: View { @Binding var isPresented: Bool @ObservedObject private var manager = LocationChannelManager.shared @ObservedObject private var bookmarks = GeohashBookmarksStore.shared @ObservedObject private var network = NetworkActivationService.shared @EnvironmentObject var viewModel: ChatViewModel @Environment(\.colorScheme) var colorScheme @State private var customGeohash: String = "" @State private var customError: String? = nil private var backgroundColor: Color { colorScheme == .dark ? .black : .white } private enum Strings { static let title: LocalizedStringKey = "location_channels.title" static let description: LocalizedStringKey = "location_channels.description" static let requestPermissions: LocalizedStringKey = "location_channels.action.request_permissions" static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied" static let openSettings: LocalizedStringKey = "location_channels.action.open_settings" static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby" static let teleport: LocalizedStringKey = "location_channels.action.teleport" static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title" static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access" static let torTitle: LocalizedStringKey = "location_channels.tor.title" static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle" static let toggleOn: LocalizedStringKey = "common.toggle.on" static let toggleOff: LocalizedStringKey = "common.toggle.off" static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid") static func meshTitle(_ count: Int) -> String { let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row") return rowTitle(label: label, count: count) } static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String { // High-precision uncertainty: if count is 0 for high-precision levels, // show "?" because presence broadcasting is disabled for privacy. let isHighPrecision = (level == .neighborhood || level == .block || level == .building) if isHighPrecision && count == 0 { return String( format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"), locale: .current, level.displayName ) } return rowTitle(label: level.displayName, count: count) } static func bookmarkTitle(geohash: String, count: Int) -> String { // Check precision for bookmarks too let len = geohash.count // Neighborhood=6, Block=7, Building=8+ let isHighPrecision = (len >= 6) if isHighPrecision && count == 0 { return String( format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"), locale: .current, "#\(geohash)" ) } return rowTitle(label: "#\(geohash)", count: count) } static func subtitlePrefix(geohash: String, coverage: String) -> String { String( format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"), locale: .current, geohash, coverage ) } static func subtitle(prefix: String, name: String?) -> String { guard let name, !name.isEmpty else { return prefix } return String( format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"), locale: .current, prefix, name ) } private static func rowTitle(label: String, count: Int) -> String { String( format: String(localized: "location_channels.row_title", comment: "List row title with participant count"), locale: .current, label, count ) } } var body: some View { NavigationView { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 12) { Text(Strings.title) .font(.bitchatSystem(size: 18, design: .monospaced)) Spacer() closeButton } Text(Strings.description) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.secondary) Group { switch manager.permissionState { case LocationChannelManager.PermissionState.notDetermined: Button(action: { manager.enableLocationChannels() }) { Text(Strings.requestPermissions) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(standardGreen) .frame(maxWidth: .infinity) .padding(.vertical, 6) .background(standardGreen.opacity(0.12)) .cornerRadius(6) } .buttonStyle(.plain) case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted: VStack(alignment: .leading, spacing: 8) { Text(Strings.permissionDenied) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.secondary) Button(Strings.openSettings) { openSystemLocationSettings() } .buttonStyle(.plain) } case LocationChannelManager.PermissionState.authorized: EmptyView() } } channelList Spacer() } .padding(.horizontal, 16) .padding(.vertical, 12) .background(backgroundColor) #if os(iOS) .navigationBarTitleDisplayMode(.inline) .navigationBarHidden(true) #else .navigationTitle("") #endif } #if os(macOS) .frame(minWidth: 420, minHeight: 520) #endif .background(backgroundColor) .onAppear { // Refresh channels when opening if manager.permissionState == LocationChannelManager.PermissionState.authorized { manager.refreshChannels() } // Begin periodic refresh while sheet is open manager.beginLiveRefresh() // Geohash sampling is now managed by ChatViewModel globally } .onDisappear { manager.endLiveRefresh() } .onChange(of: manager.permissionState) { newValue in if newValue == LocationChannelManager.PermissionState.authorized { manager.refreshChannels() } } .onChange(of: manager.availableChannels) { _ in } } private var closeButton: some View { Button(action: { isPresented = false }) { Image(systemName: "xmark") .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .frame(width: 32, height: 32) } .buttonStyle(.plain) .accessibilityLabel("Close") } private var channelList: some View { ScrollView { LazyVStack(spacing: 0) { channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) { manager.select(ChannelID.mesh) isPresented = false } .padding(.vertical, 6) let nearby = manager.availableChannels.filter { $0.level != .building } if !nearby.isEmpty { ForEach(nearby) { channel in sectionDivider let coverage = coverageString(forPrecision: channel.geohash.count) let nameBase = locationName(for: channel.level) let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 } let participantCount = viewModel.geohashParticipantCount(for: channel.geohash) let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage) let highlight = participantCount > 0 channelRow( title: Strings.levelTitle(for: channel.level, count: participantCount), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight, trailingAccessory: { Button(action: { bookmarks.toggle(channel.geohash) }) { Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") .font(.bitchatSystem(size: 14)) } .buttonStyle(.plain) .padding(.leading, 8) } ) { manager.markTeleported(for: channel.geohash, false) manager.select(ChannelID.location(channel)) isPresented = false } .padding(.vertical, 6) } } else { sectionDivider HStack(spacing: 8) { ProgressView() Text(Strings.loadingNearby) .font(.bitchatSystem(size: 12, design: .monospaced)) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 10) } sectionDivider customTeleportSection .padding(.vertical, 8) let bookmarkedList = bookmarks.bookmarks if !bookmarkedList.isEmpty { sectionDivider bookmarkedSection(bookmarkedList) .padding(.vertical, 8) } if manager.permissionState == LocationChannelManager.PermissionState.authorized { sectionDivider torToggleSection .padding(.top, 12) Button(action: { openSystemLocationSettings() }) { Text(Strings.removeAccess) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1)) .frame(maxWidth: .infinity) .padding(.vertical, 6) .background(Color.red.opacity(0.08)) .cornerRadius(6) } .buttonStyle(.plain) .padding(.vertical, 8) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 6) .background(backgroundColor) } .background(backgroundColor) } private var sectionDivider: some View { Rectangle() .fill(dividerColor) .frame(height: 1) } private var dividerColor: Color { colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08) } private var customTeleportSection: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 2) { Text(verbatim: "#") .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(.secondary) TextField("geohash", text: $customGeohash) #if os(iOS) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) .keyboardType(.asciiCapable) #endif .font(.bitchatSystem(size: 14, design: .monospaced)) .onChange(of: customGeohash) { newValue in let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let filtered = newValue .lowercased() .replacingOccurrences(of: "#", with: "") .filter { allowed.contains($0) } if filtered.count > 12 { customGeohash = String(filtered.prefix(12)) } else if filtered != newValue { customGeohash = filtered } } let normalized = customGeohash .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .replacingOccurrences(of: "#", with: "") let isValid = validateGeohash(normalized) Button(action: { let gh = normalized guard isValid else { customError = Strings.invalidGeohash; return } let level = levelForLength(gh.count) let ch = GeohashChannel(level: level, geohash: gh) manager.markTeleported(for: ch.geohash, true) manager.select(ChannelID.location(ch)) isPresented = false }) { HStack(spacing: 6) { Text(Strings.teleport) .font(.bitchatSystem(size: 14, design: .monospaced)) Image(systemName: "face.dashed") .font(.bitchatSystem(size: 14)) } } .buttonStyle(.plain) .font(.bitchatSystem(size: 14, design: .monospaced)) .padding(.vertical, 6) .padding(.horizontal, 10) .background(Color.secondary.opacity(0.12)) .cornerRadius(6) .opacity(isValid ? 1.0 : 0.4) .disabled(!isValid) } if let err = customError { Text(err) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.red) } } } private func bookmarkedSection(_ entries: [String]) -> some View { VStack(alignment: .leading, spacing: 8) { Text(Strings.bookmarked) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.secondary) LazyVStack(spacing: 0) { ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in let level = levelForLength(gh.count) let channel = GeohashChannel(level: level, geohash: gh) let coverage = coverageString(forPrecision: gh.count) let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage) let name = bookmarks.bookmarkNames[gh] let participantCount = viewModel.geohashParticipantCount(for: gh) channelRow( title: Strings.bookmarkTitle(geohash: gh, count: participantCount), subtitlePrefix: subtitle, subtitleName: name.map { formattedNamePrefix(for: level) + $0 }, isSelected: isSelected(channel), trailingAccessory: { Button(action: { bookmarks.toggle(gh) }) { Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark") .font(.bitchatSystem(size: 14)) } .buttonStyle(.plain) .padding(.leading, 8) } ) { let inRegional = manager.availableChannels.contains { $0.geohash == gh } if !inRegional && !manager.availableChannels.isEmpty { manager.markTeleported(for: gh, true) } else { manager.markTeleported(for: gh, false) } manager.select(ChannelID.location(channel)) isPresented = false } .padding(.vertical, 6) .onAppear { bookmarks.resolveBookmarkNameIfNeeded(for: gh) } if index < entries.count - 1 { sectionDivider } } } } } private func isSelected(_ channel: GeohashChannel) -> Bool { if case .location(let ch) = manager.selectedChannel { return ch == channel } return false } private var isMeshSelected: Bool { if case .mesh = manager.selectedChannel { return true } return false } @ViewBuilder private func channelRow( title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, @ViewBuilder trailingAccessory: () -> some View = { EmptyView() }, action: @escaping () -> Void ) -> some View { HStack(alignment: .center, spacing: 8) { VStack(alignment: .leading) { // Render title with smaller font for trailing count in parentheses let parts = splitTitleAndCount(title) HStack(spacing: 4) { Text(parts.base) .font(.bitchatSystem(size: 14, design: .monospaced)) .fontWeight(titleBold ? .bold : .regular) .foregroundColor(titleColor ?? Color.primary) if let count = parts.countSuffix, !count.isEmpty { Text(count) .font(.bitchatSystem(size: 11, design: .monospaced)) .foregroundColor(.secondary) } } let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName) Text(subtitleFull) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.tail) } Spacer() if isSelected { Text(verbatim: "✔︎") .font(.bitchatSystem(size: 16, design: .monospaced)) .foregroundColor(standardGreen) } trailingAccessory() } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture(perform: action) } // Split a title like "#mesh [3 people]" into base and suffix "[3 people]" private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) { guard let idx = s.lastIndex(of: "[") else { return (s, nil) } let prefix = String(s[.. Int { // Count mesh-connected OR mesh-reachable peers (exclude self) let myID = viewModel.meshService.myPeerID return viewModel.allPeers.reduce(0) { acc, peer in if peer.peerID != myID && (peer.isConnected || peer.isReachable) { return acc + 1 } return acc } } private func validateGeohash(_ s: String) -> Bool { let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") guard !s.isEmpty, s.count <= 12 else { return false } return s.allSatisfy { allowed.contains($0) } } private func levelForLength(_ len: Int) -> GeohashChannelLevel { switch len { case 0...2: return .region case 3...4: return .province case 5: return .city case 6: return .neighborhood case 7: return .block case 8: return .building default: return .block } } } // MARK: - TOR Toggle & Standardized Colors extension LocationChannelsSheet { private var torToggleBinding: Binding { Binding( get: { network.userTorEnabled }, set: { network.setUserTorEnabled($0) } ) } private var torToggleSection: some View { VStack(alignment: .leading, spacing: 8) { Toggle(isOn: torToggleBinding) { VStack(alignment: .leading, spacing: 2) { Text(Strings.torTitle) .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) .foregroundColor(.primary) Text(Strings.torSubtitle) .font(.bitchatSystem(size: 11, design: .monospaced)) .foregroundColor(.secondary) } } .toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) } .padding(12) .background(Color.secondary.opacity(0.12)) .cornerRadius(8) } private var standardGreen: Color { (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) } private var standardBlue: Color { Color(red: 0.0, green: 0.478, blue: 1.0) } } private struct IRCToggleStyle: ToggleStyle { let accent: Color let onLabel: LocalizedStringKey let offLabel: LocalizedStringKey func makeBody(configuration: Configuration) -> some View { Button(action: { configuration.isOn.toggle() }) { HStack(spacing: 12) { configuration.label Spacer() Text(configuration.isOn ? onLabel : offLabel) .textCase(.uppercase) .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) .foregroundColor(configuration.isOn ? accent : .secondary) .padding(.vertical, 4) .padding(.horizontal, 10) .background( RoundedRectangle(cornerRadius: 6) .fill(accent.opacity(configuration.isOn ? 0.18 : 0.08)) ) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1) ) } } .buttonStyle(.plain) } } // MARK: - Coverage helpers extension LocationChannelsSheet { private func coverageString(forPrecision len: Int) -> String { // Approximate max cell dimension at equator for a given geohash length. // Values sourced from common geohash dimension tables. let maxMeters: Double = { switch len { case 2: return 1_250_000 case 3: return 156_000 case 4: return 39_100 case 5: return 4_890 case 6: return 1_220 case 7: return 153 case 8: return 38.2 case 9: return 4.77 case 10: return 1.19 default: if len <= 1 { return 5_000_000 } // For >10, scale down conservatively by ~1/4 each char let over = len - 10 return 1.19 * pow(0.25, Double(over)) } }() let usesMetric: Bool = { if #available(iOS 16.0, macOS 13.0, *) { return Locale.current.measurementSystem == .metric } else { return Locale.current.usesMetricSystem } }() if usesMetric { let km = maxMeters / 1000.0 return "~\(formatDistance(km)) km" } else { let miles = maxMeters / 1609.344 return "~\(formatDistance(miles)) mi" } } private func formatDistance(_ value: Double) -> String { if value >= 100 { return String(format: "%.0f", value.rounded()) } if value >= 10 { return String(format: "%.1f", value) } return String(format: "%.1f", value) } private func bluetoothRangeString() -> String { let usesMetric: Bool = { if #available(iOS 16.0, macOS 13.0, *) { return Locale.current.measurementSystem == .metric } else { return Locale.current.usesMetricSystem } }() // Approximate Bluetooth LE range for typical mobile devices; environment dependent return usesMetric ? "~10–50 m" : "~30–160 ft" } private func locationName(for level: GeohashChannelLevel) -> String? { manager.locationNames[level] } private func formattedNamePrefix(for level: GeohashChannelLevel) -> String { switch level { case .region: return "" case .building, .block, .neighborhood, .city, .province: return "~" } } } // MARK: - Open Settings helper private func openSystemLocationSettings() { #if os(iOS) if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } #else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") { NSWorkspace.shared.open(url) } else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") { NSWorkspace.shared.open(url) } #endif } ================================================ FILE: bitchat/Views/LocationNotesView.swift ================================================ import SwiftUI struct LocationNotesView: View { @EnvironmentObject var viewModel: ChatViewModel @StateObject private var manager: LocationNotesManager let geohash: String let onNotesCountChanged: ((Int) -> Void)? @Environment(\.colorScheme) var colorScheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize @ObservedObject private var locationManager = LocationChannelManager.shared @Environment(\.dismiss) private var dismiss @State private var draft: String = "" init( geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil, manager: LocationNotesManager? = nil ) { let gh = geohash.lowercased() self.geohash = gh self.onNotesCountChanged = onNotesCountChanged _manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) } private var backgroundColor: Color { colorScheme == .dark ? .black : .white } private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) } private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } private enum Strings { static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons") static let description: LocalizedStringKey = "location_notes.description" static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent" static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused" static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby" static let retry: LocalizedStringKey = "location_notes.action.retry" static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint" static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes" static let emptyTitle: LocalizedStringKey = "location_notes.empty_title" static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle" static let dismissError: LocalizedStringKey = "location_notes.action.dismiss" static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder" } var body: some View { #if os(macOS) VStack(spacing: 0) { ScrollView { VStack(spacing: 0) { headerSection notesContent } } .background(backgroundColor) inputSection } .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) .background(backgroundColor) .onDisappear { manager.cancel() } .onChange(of: geohash) { newValue in manager.setGeohash(newValue) } .onAppear { onNotesCountChanged?(manager.notes.count) } .onChange(of: manager.notes.count) { newValue in onNotesCountChanged?(newValue) } #else NavigationView { VStack(spacing: 0) { headerSection ScrollView { notesContent } .frame(maxWidth: .infinity, maxHeight: .infinity) inputSection } .background(backgroundColor) #if os(iOS) .navigationBarTitleDisplayMode(.inline) .navigationBarHidden(true) #else .navigationTitle("") #endif } .background(backgroundColor) .onDisappear { manager.cancel() } .onChange(of: geohash) { newValue in manager.setGeohash(newValue) } .onAppear { onNotesCountChanged?(manager.notes.count) } .onChange(of: manager.notes.count) { newValue in onNotesCountChanged?(newValue) } #endif } private var closeButton: some View { Button(action: { dismiss() }) { Image(systemName: "xmark") .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .frame(width: 32, height: 32) } .buttonStyle(.plain) .accessibilityLabel(Strings.closeAccessibility) } private var headerSection: some View { let count = manager.notes.count return VStack(alignment: .leading, spacing: 8) { HStack(spacing: 12) { Text(headerTitle(for: count)) .font(.bitchatSystem(size: 18, design: .monospaced)) Spacer() closeButton } if let building = locationManager.locationNames[.building], !building.isEmpty { Text(building) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(accentGreen) } else if let block = locationManager.locationNames[.block], !block.isEmpty { Text(block) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(accentGreen) } Text(Strings.description) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) if manager.state == .noRelays { Text(Strings.relaysPaused) .font(.bitchatSystem(size: 11, design: .monospaced)) .foregroundColor(.secondary) } } .padding(.horizontal, 16) .padding(.top, 16) .padding(.bottom, 12) .background(backgroundColor) } private func headerTitle(for count: Int) -> String { String( format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"), locale: .current, "\(geohash) ± 1", count ) } private var notesContent: some View { LazyVStack(alignment: .leading, spacing: 12) { if manager.state == .noRelays { noRelaysRow } else if manager.state == .loading && !manager.initialLoadComplete { loadingRow } else if manager.notes.isEmpty { emptyRow } else { ForEach(manager.notes) { note in noteRow(note) } } if let error = manager.errorMessage, manager.state != .noRelays { errorRow(message: error) } } .padding(.horizontal, 16) .padding(.vertical, 8) } private func noteRow(_ note: LocationNotesManager.Note) -> some View { let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName let ts = timestampText(for: note.createdAt) return VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(verbatim: "@\(baseName)") .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) if !ts.isEmpty { Text(ts) .font(.bitchatSystem(size: 11, design: .monospaced)) .foregroundColor(.secondary) } Spacer() } Text(note.content) .font(.bitchatSystem(size: 14, design: .monospaced)) .fixedSize(horizontal: false, vertical: true) } .padding(.vertical, 4) } private var noRelaysRow: some View { VStack(alignment: .leading, spacing: 4) { Text(Strings.noRelaysNearby) .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) Text(Strings.relaysRetryHint) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.secondary) Button(Strings.retry) { manager.refresh() } .font(.bitchatSystem(size: 12, design: .monospaced)) .buttonStyle(.plain) } .padding(.vertical, 6) } private var loadingRow: some View { HStack(spacing: 10) { ProgressView() Text(Strings.loadingNotes) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.secondary) Spacer() } .padding(.vertical, 8) } private var emptyRow: some View { VStack(alignment: .leading, spacing: 4) { Text(Strings.emptyTitle) .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) Text(Strings.emptySubtitle) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.secondary) } .padding(.vertical, 6) } private func errorRow(message: String) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .font(.bitchatSystem(size: 12, design: .monospaced)) Text(message) .font(.bitchatSystem(size: 12, design: .monospaced)) Spacer() } Button(Strings.dismissError) { manager.clearError() } .font(.bitchatSystem(size: 12, design: .monospaced)) .buttonStyle(.plain) } .padding(.vertical, 6) } private var inputSection: some View { HStack(alignment: .top, spacing: 10) { TextField(Strings.addPlaceholder, text: $draft, axis: .vertical) .textFieldStyle(.plain) .font(.bitchatSystem(size: 14, design: .monospaced)) .lineLimit(maxDraftLines, reservesSpace: true) .padding(.vertical, 6) Button(action: send) { Image(systemName: "arrow.up.circle.fill") .font(.bitchatSystem(size: 20)) .foregroundColor(sendButtonEnabled ? accentGreen : .secondary) } .padding(.top, 2) .buttonStyle(.plain) .disabled(!sendButtonEnabled) } .padding(.horizontal, 16) .padding(.vertical, 14) .background(backgroundColor) .overlay(Divider(), alignment: .top) } private func send() { let content = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty else { return } manager.send(content: content, nickname: viewModel.nickname) draft = "" } private var sendButtonEnabled: Bool { !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays } // MARK: - Timestamp Formatting private func timestampText(for date: Date) -> String { let now = Date() if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" return rel.isEmpty ? "" : "\(rel) ago" } else { let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter return fmt.string(from: date) } } private static let relativeFormatter: DateComponentsFormatter = { let f = DateComponentsFormatter() f.allowedUnits = [.day, .hour, .minute] f.maximumUnitCount = 1 f.unitsStyle = .abbreviated f.collapsesLargestUnit = true return f }() private static let absDateFormatter: DateFormatter = { let f = DateFormatter() f.setLocalizedDateFormatFromTemplate("MMM d") return f }() private static let absDateYearFormatter: DateFormatter = { let f = DateFormatter() f.setLocalizedDateFormatFromTemplate("MMM d, y") return f }() } ================================================ FILE: bitchat/Views/Media/BlockRevealImageView.swift ================================================ import SwiftUI #if os(iOS) import UIKit private typealias PlatformImage = UIImage #else import AppKit private typealias PlatformImage = NSImage #endif struct BlockRevealImageView: View { private let url: URL private let revealProgress: Double? private let isSending: Bool private let onCancel: (() -> Void)? private let initiallyBlurred: Bool private let onOpen: (() -> Void)? private let onDelete: (() -> Void)? @State private var platformImage: PlatformImage? @State private var aspectRatio: CGFloat = 1 @State private var isBlurred: Bool = false init( url: URL, revealProgress: Double?, isSending: Bool, onCancel: (() -> Void)?, initiallyBlurred: Bool = false, onOpen: (() -> Void)? = nil, onDelete: (() -> Void)? = nil ) { self.url = url self.revealProgress = revealProgress self.isSending = isSending self.onCancel = onCancel self.initiallyBlurred = initiallyBlurred self.onOpen = onOpen self.onDelete = onDelete } private var fraction: Double { guard let revealProgress = revealProgress else { return 1 } return max(0, min(1, revealProgress)) } var body: some View { ZStack(alignment: .topTrailing) { if let image = platformImage { Image(platformImage: image) .resizable() .aspectRatio(aspectRatio, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(Color.gray.opacity(0.2), lineWidth: 1) ) .mask( BlockRevealMask( fraction: fraction, columns: 24, rows: 16 ) .animation(.easeOut(duration: 0.2), value: fraction) ) .blur(radius: isBlurred ? 20 : 0) .overlay { if isBlurred { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.black.opacity(0.35)) .overlay( Image(systemName: "eye.slash.fill") .font(.bitchatSystem(size: 24, weight: .semibold)) .foregroundColor(.white.opacity(0.85)) ) } } } else { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.gray.opacity(0.2)) .frame(height: 200) .overlay( ProgressView() .progressViewStyle(.circular) ) } if let onCancel = onCancel, isSending { Button(action: onCancel) { Image(systemName: "xmark") .font(.bitchatSystem(size: 12, weight: .bold)) .padding(8) .background(Circle().fill(Color.black.opacity(0.7))) .foregroundColor(.white) .padding(8) } .buttonStyle(.plain) } } .onAppear { isBlurred = initiallyBlurred loadImage() } .onChange(of: url) { _ in isBlurred = initiallyBlurred loadImage() } .gesture(mainGesture) } private func loadImage() { DispatchQueue.global(qos: .userInitiated).async { #if os(iOS) guard let image = UIImage(contentsOfFile: url.path) else { return } #else guard let image = NSImage(contentsOf: url) else { return } #endif let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1 DispatchQueue.main.async { self.platformImage = image self.aspectRatio = ratio } } } private var mainGesture: some Gesture { let doubleTap = TapGesture(count: 2).onEnded { guard !isSending else { return } onDelete?() } let singleTap = TapGesture().onEnded { guard !isSending else { return } if isBlurred { withAnimation(.easeOut(duration: 0.2)) { isBlurred = false } } else { onOpen?() } } let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in guard !isSending else { return } let horizontal = value.translation.width let vertical = value.translation.height guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return } if !isBlurred { withAnimation(.easeInOut(duration: 0.2)) { isBlurred = true } } } return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe) } } private struct BlockRevealMask: Shape { let fraction: Double let columns: Int let rows: Int func path(in rect: CGRect) -> Path { var path = Path() guard fraction > 0, columns > 0, rows > 0 else { return path } let totalBlocks = columns * rows let revealCount = max(0, min(totalBlocks, Int(ceil(fraction * Double(totalBlocks))))) guard revealCount > 0 else { return path } let blockWidth = rect.width / CGFloat(columns) let blockHeight = rect.height / CGFloat(rows) var remaining = revealCount for row in 0.. Void)? @Environment(\.colorScheme) private var colorScheme @StateObject private var playback: VoiceNotePlaybackController @State private var waveform: [Float] = [] init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) { self.url = url self.isSending = isSending self.sendProgress = sendProgress self.onCancel = onCancel _playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url)) } private var samples: [Float] { if waveform.isEmpty { return Array(repeating: 0.25, count: 64) } return waveform } private var backgroundColor: Color { colorScheme == .dark ? Color.black.opacity(0.6) : Color.white } private var borderColor: Color { colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2) } private var durationText: String { let duration = playback.duration guard duration.isFinite, duration > 0 else { return "--:--" } let minutes = Int(duration) / 60 let seconds = Int(duration) % 60 return String(format: "%02d:%02d", minutes, seconds) } private var currentText: String { let current = playback.currentTime guard current.isFinite, current > 0 else { return "00:00" } let minutes = Int(current) / 60 let seconds = Int(current) % 60 return String(format: "%02d:%02d", minutes, seconds) } private var playbackLabel: String { playback.isPlaying ? currentText + "/" + durationText : durationText } var body: some View { HStack(spacing: 12) { Button(action: playback.togglePlayback) { Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill") .foregroundColor(.white) .frame(width: 36, height: 36) .background(Circle().fill(Color.green)) } .buttonStyle(.plain) WaveformView( samples: samples, playbackProgress: playback.progress, sendProgress: sendProgress, onSeek: { fraction in playback.seek(to: fraction) }, isInteractive: playback.isPlaying ) Text(playbackLabel) .font(.bitchatSystem(size: 13, design: .monospaced)) .foregroundColor(Color.secondary) if let onCancel = onCancel, isSending { Button(action: onCancel) { Image(systemName: "xmark") .font(.bitchatSystem(size: 12, weight: .bold)) .frame(width: 28, height: 28) .background(Circle().fill(Color.red.opacity(0.9))) .foregroundColor(.white) } .buttonStyle(.plain) } } .padding(12) .background( RoundedRectangle(cornerRadius: 14) .fill(backgroundColor) .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.3 : 0.1), radius: 6, x: 0, y: 2) ) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(borderColor, lineWidth: 1) ) .task { // Defer loading to let UI settle after view appears try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s playback.loadDuration() await withCheckedContinuation { continuation in WaveformCache.shared.waveform(for: url, completion: { bins in waveform = bins continuation.resume() }) } } .onChange(of: url) { newValue in WaveformCache.shared.waveform(for: newValue, completion: { bins in self.waveform = bins }) playback.replaceURL(newValue) } .onDisappear { playback.stop() } } } ================================================ FILE: bitchat/Views/Media/WaveformView.swift ================================================ import SwiftUI struct WaveformView: View { let samples: [Float] let playbackProgress: Double let sendProgress: Double? let onSeek: ((Double) -> Void)? let isInteractive: Bool private var clampedPlayback: Double { max(0, min(1, playbackProgress)) } private var clampedSend: Double? { guard let sendProgress = sendProgress else { return nil } return max(0, min(1, sendProgress)) } var body: some View { GeometryReader { geometry in ZStack { Canvas { context, size in guard !samples.isEmpty else { return } let width = max(size.width, 1) let height = max(size.height, 1) let barWidth = max(width / CGFloat(samples.count), 1) for (index, sample) in samples.enumerated() { let normalized = max(0, min(sample, 1)) let barHeight = CGFloat(normalized) * height let originX = CGFloat(index) * barWidth let rect = CGRect( x: originX, y: (height - barHeight) / 2, width: max(barWidth * 0.7, 1), height: barHeight ) let binPosition = Double(index) / Double(samples.count) let color: Color if binPosition <= clampedPlayback { color = Color.green } else if let send = clampedSend, binPosition <= send { color = Color.blue } else { color = Color.gray.opacity(0.35) } context.fill(Path(rect), with: .color(color)) } } .frame(width: geometry.size.width, height: geometry.size.height) if isInteractive, let onSeek = onSeek { Color.clear .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onEnded { value in guard geometry.size.width > 0 else { return } let fraction = max(0, min(1, value.location.x / geometry.size.width)) onSeek(fraction) } ) } } } .frame(height: 48) } } ================================================ FILE: bitchat/Views/MeshPeerList.swift ================================================ import SwiftUI struct MeshPeerList: View { @ObservedObject var viewModel: ChatViewModel let textColor: Color let secondaryTextColor: Color let onTapPeer: (PeerID) -> Void let onToggleFavorite: (PeerID) -> Void let onShowFingerprint: (PeerID) -> Void @Environment(\.colorScheme) var colorScheme @State private var orderedIDs: [String] = [] private enum Strings { static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby" static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator") static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") } var body: some View { let myPeerID = viewModel.meshService.myPeerID let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in let isMe = peer.peerID == myPeerID let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID) let enc = viewModel.getEncryptionStatus(for: peer.peerID) return (peer, isMe, hasUnread, enc) } // Stable visual order without mutating state here let currentIDs = mapped.map { $0.peer.peerID.id } let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in mapped.first(where: { $0.peer.peerID.id == id }) } if viewModel.allPeers.isEmpty { VStack(alignment: .leading, spacing: 0) { Text(Strings.noneNearby) .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(secondaryTextColor) .padding(.horizontal) .padding(.top, 12) } } else { VStack(alignment: .leading, spacing: 0) { ForEach(0.. Bool { var current = 0 for ch in self { if ch.isWhitespace || ch.isNewline { if current >= threshold { return true } current = 0 } else { current += 1 if current >= threshold { return true } } } return current >= threshold } // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. func extractCashuLinks(max: Int = 3) -> [String] { let regex = MessageFormattingEngine.Patterns.cashu let ns = self as NSString let range = NSRange(location: 0, length: ns.length) var found: [String] = [] for m in regex.matches(in: self, range: range) { if m.numberOfRanges > 0 { let token = ns.substring(with: m.range(at: 0)) let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token found.append("cashu:\(enc)") if found.count >= max { break } } } return found } // Extract Lightning payloads (scheme, BOLT11, LNURL). Returned as lightning: func extractLightningLinks(max: Int = 3) -> [String] { var results: [String] = [] let ns = self as NSString let full = NSRange(location: 0, length: ns.length) // lightning: scheme for m in MessageFormattingEngine.Patterns.lightningScheme.matches(in: self, range: full) { let s = ns.substring(with: m.range(at: 0)) results.append(s) if results.count >= max { return results } } // BOLT11 for m in MessageFormattingEngine.Patterns.bolt11.matches(in: self, range: full) { let s = ns.substring(with: m.range(at: 0)) results.append("lightning:\(s)") if results.count >= max { return results } } // LNURL bech32 for m in MessageFormattingEngine.Patterns.lnurl.matches(in: self, range: full) { let s = ns.substring(with: m.range(at: 0)) results.append("lightning:\(s)") if results.count >= max { return results } } return results } } ================================================ FILE: bitchat/Views/VerificationViews.swift ================================================ import SwiftUI import CoreImage import CoreImage.CIFilterBuiltins #if os(iOS) import UIKit #else import AppKit #endif /// Placeholder view to display the user's verification QR payload as text. struct MyQRView: View { let qrString: String @Environment(\.colorScheme) var colorScheme private var boxColor: Color { Color.gray.opacity(0.1) } private enum Strings { static let title: LocalizedStringKey = "verification.my_qr.title" static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", comment: "Accessibility label describing the verification QR code") } var body: some View { VStack(spacing: 12) { Text(Strings.title) .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) VStack(spacing: 10) { QRCodeImage(data: qrString, size: 240) .accessibilityLabel(Strings.accessibilityLabel) // Non-scrolling, fully visible URL (wraps across lines) Text(qrString) .font(.bitchatSystem(size: 11, design: .monospaced)) .textSelection(.enabled) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) .padding(8) .background(boxColor) .cornerRadius(8) } .padding() .frame(maxWidth: .infinity) .background(boxColor) .cornerRadius(8) } .padding() } } // Render a QR code image for a given string using CoreImage struct QRCodeImage: View { let data: String let size: CGFloat private let context = CIContext() private let filter = CIFilter.qrCodeGenerator() private enum Strings { static let unavailable: LocalizedStringKey = "verification.my_qr.unavailable" } var body: some View { Group { if let image = generateImage() { ImageWrapper(image: image) .frame(width: size, height: size) } else { RoundedRectangle(cornerRadius: 8) .stroke(Color.gray.opacity(0.5), lineWidth: 1) .frame(width: size, height: size) .overlay( Text(Strings.unavailable) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.gray) ) } } } private func generateImage() -> CGImage? { let inputData = Data(data.utf8) filter.message = inputData filter.correctionLevel = "M" guard let outputImage = filter.outputImage else { return nil } let scale = max(1, Int(size / 32)) let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale))) return context.createCGImage(transformed, from: transformed.extent) } } // Platform-specific wrapper to display CGImage in SwiftUI struct ImageWrapper: View { let image: CGImage var body: some View { #if os(iOS) let ui = UIImage(cgImage: image) return Image(uiImage: ui) .interpolation(.none) .resizable() #else let ns = NSImage(cgImage: image, size: .zero) return Image(nsImage: ns) .interpolation(.none) .resizable() #endif } } /// Placeholder scanner UI; real camera scanning will be added later. struct QRScanView: View { @EnvironmentObject var viewModel: ChatViewModel var isActive: Bool = true var onSuccess: (() -> Void)? = nil // Called when verification succeeds @State private var input = "" @State private var result: String = "" // not shown for iOS scanner @State private var lastValid: String = "" private enum Strings { static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt" static let validate: LocalizedStringKey = "verification.scan.validate" static func requested(_ nickname: String) -> String { String( format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"), locale: .current, nickname ) } static let notFound = String(localized: "verification.scan.status.no_peer", comment: "Status when no matching peer is found for a verification request") static let invalid = String(localized: "verification.scan.status.invalid", comment: "Status when a scanned QR payload is invalid") } var body: some View { VStack(alignment: .leading, spacing: 12) { #if os(iOS) CameraScannerView(isActive: isActive) { code in // Deduplicate: ignore if we just processed this exact QR code guard code != lastValid else { return } if let qr = VerificationService.shared.verifyScannedQR(code) { let ok = viewModel.beginQRVerification(with: qr) if ok { // Successfully initiated verification; remember this QR to prevent re-scanning lastValid = code // Close scanner and return to "My QR" view onSuccess?() } // If !ok, peer not found or already pending - don't set lastValid so user can retry } else { // ignore invalid reads; continue scanning } } .frame(height: 260) .clipShape(RoundedRectangle(cornerRadius: 8)) #else Text(Strings.pastePrompt) .font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced)) TextEditor(text: $input) .frame(height: 100) .border(Color.gray.opacity(0.4)) Button(Strings.validate) { // Deduplicate: ignore if we just processed this exact QR guard input != lastValid else { result = Strings.requested("") // Already processed return } if let qr = VerificationService.shared.verifyScannedQR(input) { let ok = viewModel.beginQRVerification(with: qr) if ok { result = Strings.requested(qr.nickname) lastValid = input // Close scanner and return to "My QR" view onSuccess?() } else { result = Strings.notFound } } else { result = Strings.invalid } } .buttonStyle(.bordered) #endif // No status text under camera per design Spacer() } .padding() } } #if os(iOS) import AVFoundation struct CameraScannerView: UIViewRepresentable { typealias UIViewType = PreviewView var isActive: Bool var onCode: (String) -> Void func makeUIView(context: Context) -> PreviewView { let view = PreviewView() context.coordinator.setup(sessionOwner: view, onCode: onCode) context.coordinator.setActive(isActive) return view } func updateUIView(_ uiView: PreviewView, context: Context) { context.coordinator.setActive(isActive) } func makeCoordinator() -> Coordinator { Coordinator() } final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { private var onCode: ((String) -> Void)? private weak var owner: PreviewView? private let session = AVCaptureSession() private var isRunning = false private var permissionGranted = false private var desiredActive = false func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) { self.owner = sessionOwner self.onCode = onCode session.beginConfiguration() session.sessionPreset = .high guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { return } session.addInput(input) let output = AVCaptureMetadataOutput() guard session.canAddOutput(output) else { return } session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) if output.availableMetadataObjectTypes.contains(.qr) { output.metadataObjectTypes = [.qr] } session.commitConfiguration() sessionOwner.videoPreviewLayer.session = session // Request permission and start AVCaptureDevice.requestAccess(for: .video) { granted in self.permissionGranted = granted if granted && self.desiredActive && !self.isRunning { self.setActive(true) } } } func setActive(_ active: Bool) { desiredActive = active guard permissionGranted else { return } if active && !isRunning { isRunning = true DispatchQueue.global(qos: .userInitiated).async { if !self.session.isRunning { self.session.startRunning() } } } else if !active && isRunning { isRunning = false DispatchQueue.global(qos: .userInitiated).async { if self.session.isRunning { self.session.stopRunning() } } } } func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { for obj in metadataObjects { guard let m = obj as? AVMetadataMachineReadableCodeObject, m.type == .qr, let str = m.stringValue else { continue } onCode?(str) } } } final class PreviewView: UIView { override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } override init(frame: CGRect) { super.init(frame: frame) videoPreviewLayer.videoGravity = .resizeAspectFill } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } } #endif // Combined sheet: shows my QR by default with a button to scan instead struct VerificationSheetView: View { @EnvironmentObject var viewModel: ChatViewModel @Binding var isPresented: Bool @State private var showingScanner = false @Environment(\.colorScheme) var colorScheme private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white } private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } private var boxColor: Color { Color.gray.opacity(0.1) } private func myQRString() -> String { let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? "" } var body: some View { VStack(spacing: 0) { // Top header (always at top) HStack { Text("verification.sheet.title") .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .foregroundColor(accentColor) Spacer() Button(action: { showingScanner = false isPresented = false }) { Image(systemName: "xmark") .font(.bitchatSystem(size: 14, weight: .semibold)) .foregroundColor(accentColor) } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 8) Divider() // Content area Group { if showingScanner { VStack(alignment: .leading, spacing: 12) { Text("verification.scan.prompt_friend") .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) .foregroundColor(accentColor) #if os(iOS) QRScanView(isActive: showingScanner, onSuccess: { showingScanner = false }) .environmentObject(viewModel) .frame(height: 280) .clipShape(RoundedRectangle(cornerRadius: 10)) #else QRScanView(onSuccess: { showingScanner = false }) .environmentObject(viewModel) #endif } .padding() .frame(maxWidth: .infinity) .background(boxColor) .cornerRadius(8) } else { let qr = myQRString() MyQRView(qrString: qr) } } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) // Centered controls moved up VStack(spacing: 10) { if showingScanner { Button(action: { showingScanner = false }) { Label("show my qr", systemImage: "qrcode") .font(.bitchatSystem(size: 13, design: .monospaced)) } .buttonStyle(.bordered) } else { Button(action: { showingScanner = true }) { Label("scan someone else's qr", systemImage: "camera.viewfinder") .font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced)) } .buttonStyle(.bordered) .tint(.gray) } // Optional: Remove verification for selected peer (if verified) if let pid = viewModel.selectedPrivateChatPeer, let fp = viewModel.getFingerprint(for: pid), viewModel.verifiedFingerprints.contains(fp) { Button(action: { viewModel.unverifyFingerprint(for: pid) }) { Label("remove verification", systemImage: "minus.circle") .font(.bitchatSystem(size: 12, design: .monospaced)) } .buttonStyle(.bordered) .tint(.gray) } } .frame(maxWidth: .infinity) .padding(.vertical, 14) } .background(backgroundColor) .onDisappear { showingScanner = false } } } ================================================ FILE: bitchat/_PreviewHelpers/BitchatMessage+Preview.swift ================================================ // // BitchatMessage+Preview.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation extension BitchatMessage { static var preview: BitchatMessage { BitchatMessage( id: UUID().uuidString, sender: "John Doe", content: "Hello", timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: false, recipientNickname: "Jane Doe", senderPeerID: nil, mentions: nil, deliveryStatus: .sent ) } } ================================================ FILE: bitchat/_PreviewHelpers/PreviewKeychainManager.swift ================================================ // // PreviewKeychainManager.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation final class PreviewKeychainManager: KeychainManagerProtocol { private var storage: [String: Data] = [:] private var serviceStorage: [String: [String: Data]] = [:] init() {} func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { storage[key] = keyData return true } func getIdentityKey(forKey key: String) -> Data? { storage[key] } func deleteIdentityKey(forKey key: String) -> Bool { storage.removeValue(forKey: key) return true } func deleteAllKeychainData() -> Bool { storage.removeAll() serviceStorage.removeAll() return true } func secureClear(_ data: inout Data) {} func secureClear(_ string: inout String) {} func verifyIdentityKeyExists() -> Bool { storage["identity_noiseStaticKey"] != nil } // BCH-01-009: New methods with proper error classification func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { if let data = storage[key] { return .success(data) } return .itemNotFound } func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { storage[key] = keyData return .success } // MARK: - Generic Data Storage (consolidated from KeychainHelper) func save(key: String, data: Data, service: String, accessible: CFString?) { if serviceStorage[service] == nil { serviceStorage[service] = [:] } serviceStorage[service]?[key] = data } func load(key: String, service: String) -> Data? { serviceStorage[service]?[key] } func delete(key: String, service: String) { serviceStorage[service]?.removeValue(forKey: key) } } ================================================ FILE: bitchat/bitchat-macOS.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.application-groups group.chat.bitchat com.apple.security.device.bluetooth com.apple.security.device.microphone com.apple.security.personal-information.location com.apple.security.network.client com.apple.security.network.server com.apple.security.files.user-selected.read-only com.apple.security.files.user-selected.read-write com.apple.security.assets.pictures.read-only ================================================ FILE: bitchat/bitchat.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.application-groups group.chat.bitchat com.apple.security.device.bluetooth ================================================ FILE: bitchat.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 90; objects = { /* Begin PBXBuildFile section */ 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; }; 885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; }; A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; }; A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; }; A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; }; A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; }; E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; }; E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 96415D4F989854F908EAD303 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */; proxyType = 1; remoteGlobalIDString = AF077EA0474EDEDE2C72716C; remoteInfo = bitchat_iOS; }; E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */; proxyType = 1; remoteGlobalIDString = 57CA17A36A2532A6CFF367BB; remoteInfo = bitchatShareExtension; }; FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */; proxyType = 1; remoteGlobalIDString = 0576A29205865664C0937536; remoteInfo = bitchat_macOS; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ B6C356449BAE4E0F650565D1 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; dstPath = ""; dstSubfolder = PlugIns; files = ( 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "tor-nolzma.xcframework"; path = "localPackages/Tor/Frameworks/tor-nolzma.xcframework"; sourceTree = ""; }; C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Services/TransportConfig.swift, ); target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; }; A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, ); target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */; }; A6E32D1D2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_macOS" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, LaunchScreen.storyboard, ); target = 0576A29205865664C0937536 /* bitchat_macOS */; }; A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( ShareViewController.swift, ); target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; }; C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, Localization/PrimaryLocalizationKeys.json, README.md, ); target = 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */; }; C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, Localization/PrimaryLocalizationKeys.json, README.md, ); target = 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ A6E32C972E762EA70032EA8A /* bitchat */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */, A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */, A6E32D1D2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_macOS" target */, ); path = bitchat; sourceTree = ""; }; A6E32D212E762EAB0032EA8A /* bitchatShareExtension */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */, ); path = bitchatShareExtension; sourceTree = ""; }; A6E32D412E762EAE0032EA8A /* bitchatTests */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */, C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */, ); path = bitchatTests; sourceTree = ""; }; A6E367C92E76469E0032EA8A /* Configs */ = { isa = PBXFileSystemSynchronizedRootGroup; path = Configs; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ 31F6FDADA63050361C14F3A1 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */, 3EE336D150427F736F32B56C /* P256K in Frameworks */, A6E3EA812E7706A80032EA8A /* Tor in Frameworks */, ); }; B5A5CC493FFB3D8966548140 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */, 885BBED78092484A5B069461 /* P256K in Frameworks */, A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */, ); }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 18198ED912AAF495D8AF7763 = { isa = PBXGroup; children = ( A6E32C972E762EA70032EA8A /* bitchat */, E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */, A6E32D212E762EAB0032EA8A /* bitchatShareExtension */, A6E32D412E762EAE0032EA8A /* bitchatTests */, A6E367C92E76469E0032EA8A /* Configs */, 9F37F9F2C353B58AC809E93B /* Products */, A6F183FB2E948783006A9046 /* Frameworks */, ); sourceTree = ""; }; 9F37F9F2C353B58AC809E93B /* Products */ = { isa = PBXGroup; children = ( 96D0D41CA19EE5A772AA8434 /* bitchat.app */, 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */, 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */, C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */, 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */, ); name = Products; sourceTree = ""; }; A6F183FB2E948783006A9046 /* Frameworks */ = { isa = PBXGroup; children = ( A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */, ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 0576A29205865664C0937536 /* bitchat_macOS */ = { isa = PBXNativeTarget; buildConfigurationList = DA5644925338B8189B035657 /* Build configuration list for PBXNativeTarget "bitchat_macOS" */; buildPhases = ( 137ABE739BF20ACDDF8CC605 /* Sources */, 0214973A876129753D39EB47 /* Resources */, 31F6FDADA63050361C14F3A1 /* Frameworks */, ); buildRules = ( ); fileSystemSynchronizedGroups = ( A6E32C972E762EA70032EA8A /* bitchat */, ); name = bitchat_macOS; packageProductDependencies = ( B1D9136AA0083366353BFA2F /* P256K */, A6E3E5712E7703760032EA8A /* BitLogger */, A6E3EA802E7706A80032EA8A /* Tor */, ); productName = bitchat_macOS; productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */; productType = "com.apple.product-type.application"; }; 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */ = { isa = PBXNativeTarget; buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */; buildPhases = ( 5C22AA7B9ACC5A861445C769 /* Sources */, C5E027A42ECCDFD700BD6012 /* Resources */, ); buildRules = ( ); dependencies = ( 4AA8605DCAA64A45657EF0CA /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( A6E32D412E762EAE0032EA8A /* bitchatTests */, ); name = bitchatTests_macOS; productName = bitchatTests_macOS; productReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */ = { isa = PBXNativeTarget; buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */; buildPhases = ( 0A08E70F08F55FD5BA8C7EF3 /* Sources */, ); buildRules = ( ); name = bitchatShareExtension; productName = bitchatShareExtension; productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; productType = "com.apple.product-type.app-extension"; }; 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */ = { isa = PBXNativeTarget; buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */; buildPhases = ( 865C8403EF02C089369A9FCB /* Sources */, C5E027A72ECCDFE200BD6012 /* Resources */, ); buildRules = ( ); dependencies = ( D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( A6E32D412E762EAE0032EA8A /* bitchatTests */, ); name = bitchatTests_iOS; productName = bitchatTests_iOS; productReference = C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; AF077EA0474EDEDE2C72716C /* bitchat_iOS */ = { isa = PBXNativeTarget; buildConfigurationList = 53EADEF7546F94DDF82271B9 /* Build configuration list for PBXNativeTarget "bitchat_iOS" */; buildPhases = ( 4E49E34F00154C051AE90FED /* Sources */, CD6E8F32BC38357473954F97 /* Resources */, B5A5CC493FFB3D8966548140 /* Frameworks */, B6C356449BAE4E0F650565D1 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( 6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( A6E32C972E762EA70032EA8A /* bitchat */, ); name = bitchat_iOS; packageProductDependencies = ( 4EB6BA1B8464F1EA38F4E286 /* P256K */, A6E3E56F2E77036A0032EA8A /* BitLogger */, A6E3EA7E2E7706720032EA8A /* Tor */, ); productName = bitchat_iOS; productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 475D96681D0EA0AE57A4E06E /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1640; }; buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( Base, en, es, ar, de, fr, he, id, it, ja, ne, "pt-BR", ru, tr, uk, "zh-Hans", ); mainGroup = 18198ED912AAF495D8AF7763; minimizedProjectReferenceProxies = 1; packageReferences = ( B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */, A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */, A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */, ); preferredProjectObjectVersion = 90; projectDirPath = ""; projectRoot = ""; targets = ( 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */, 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */, 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */, AF077EA0474EDEDE2C72716C /* bitchat_iOS */, 0576A29205865664C0937536 /* bitchat_macOS */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 0214973A876129753D39EB47 /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */, ); }; C5E027A42ECCDFD700BD6012 /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( ); }; C5E027A72ECCDFE200BD6012 /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( ); }; CD6E8F32BC38357473954F97 /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */, ); }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 0A08E70F08F55FD5BA8C7EF3 /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( ); }; 137ABE739BF20ACDDF8CC605 /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( ); }; 4E49E34F00154C051AE90FED /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( ); }; 5C22AA7B9ACC5A861445C769 /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( ); }; 865C8403EF02C089369A9FCB /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( ); }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 4AA8605DCAA64A45657EF0CA /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 0576A29205865664C0937536 /* bitchat_macOS */; targetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */; }; 6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; targetProxy = E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */; }; D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */; targetProxy = 96415D4F989854F908EAD303 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ 077A5203074247CF8F766E2F /* Debug configuration for PBXNativeTarget "bitchatTests_iOS" */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceRelativePath = Debug.xcconfig; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).tests"; SDKROOT = iphoneos; SWIFT_VERSION = "$(SWIFT_VERSION)"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat"; }; name = Debug; }; 0DACAA261446D178EDD30ECA /* Release configuration for PBXNativeTarget "bitchatTests_iOS" */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceRelativePath = Release.xcconfig; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).tests"; SDKROOT = iphoneos; SWIFT_VERSION = "$(SWIFT_VERSION)"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat"; }; name = Release; }; 147FDAE548082D5B921C6F0B /* Release configuration for PBXNativeTarget "bitchatTests_macOS" */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceRelativePath = Release.xcconfig; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).tests"; SDKROOT = macosx; SWIFT_VERSION = "$(SWIFT_VERSION)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat"; }; name = Release; }; 3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceRelativePath = Release.xcconfig; buildSettings = { CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = "$(SWIFT_VERSION)"; TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; 702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget "bitchat_iOS" */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceRelativePath = Debug.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = NO; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = "$(SWIFT_VERSION)"; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; 7FA2BADBF3B325125030CAB1 /* Debug configuration for PBXNativeTarget "bitchatTests_macOS" */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceRelativePath = Debug.xcconfig; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).tests"; SDKROOT = macosx; SWIFT_VERSION = "$(SWIFT_VERSION)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat"; }; name = Debug; }; B36671AEACCBF92BE10852E9 /* Release configuration for PBXNativeTarget "bitchat_iOS" */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceRelativePath = Release.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = "$(SWIFT_VERSION)"; TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; BB044400A0F06B93F22D0D55 /* Release configuration for PBXNativeTarget "bitchat_macOS" */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceRelativePath = Release.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ENTITLEMENTS = "bitchat/bitchat-macOS.entitlements"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; 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", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; SDKROOT = macosx; SWIFT_VERSION = "$(SWIFT_VERSION)"; }; name = Release; }; BF0D85727BCB6E346962F419 /* Release configuration for PBXProject "bitchat" */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = "$(SWIFT_VERSION)"; }; name = Release; }; CC79F65842D42034ACEE79B7 /* Debug configuration for PBXNativeTarget "bitchat_macOS" */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceRelativePath = Debug.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ENTITLEMENTS = "bitchat/bitchat-macOS.entitlements"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = NO; 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", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; SDKROOT = macosx; SWIFT_VERSION = "$(SWIFT_VERSION)"; }; name = Debug; }; D8C5BF109BB2630752185FA0 /* Debug configuration for PBXProject "bitchat" */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "DEBUG=1", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = "$(SWIFT_VERSION)"; }; name = Debug; }; DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceRelativePath = Debug.xcconfig; buildSettings = { CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = "$(SWIFT_VERSION)"; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */ = { isa = XCConfigurationList; buildConfigurations = ( 7FA2BADBF3B325125030CAB1 /* Debug configuration for PBXNativeTarget "bitchatTests_macOS" */, 147FDAE548082D5B921C6F0B /* Release configuration for PBXNativeTarget "bitchatTests_macOS" */, ); defaultConfigurationName = Debug; }; 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */ = { isa = XCConfigurationList; buildConfigurations = ( 077A5203074247CF8F766E2F /* Debug configuration for PBXNativeTarget "bitchatTests_iOS" */, 0DACAA261446D178EDD30ECA /* Release configuration for PBXNativeTarget "bitchatTests_iOS" */, ); defaultConfigurationName = Debug; }; 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */ = { isa = XCConfigurationList; buildConfigurations = ( D8C5BF109BB2630752185FA0 /* Debug configuration for PBXProject "bitchat" */, BF0D85727BCB6E346962F419 /* Release configuration for PBXProject "bitchat" */, ); defaultConfigurationName = Debug; }; 53EADEF7546F94DDF82271B9 /* Build configuration list for PBXNativeTarget "bitchat_iOS" */ = { isa = XCConfigurationList; buildConfigurations = ( 702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget "bitchat_iOS" */, B36671AEACCBF92BE10852E9 /* Release configuration for PBXNativeTarget "bitchat_iOS" */, ); defaultConfigurationName = Debug; }; DA5644925338B8189B035657 /* Build configuration list for PBXNativeTarget "bitchat_macOS" */ = { isa = XCConfigurationList; buildConfigurations = ( CC79F65842D42034ACEE79B7 /* Debug configuration for PBXNativeTarget "bitchat_macOS" */, BB044400A0F06B93F22D0D55 /* Release configuration for PBXNativeTarget "bitchat_macOS" */, ); defaultConfigurationName = Debug; }; E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */, 3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */, ); defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = { isa = XCLocalSwiftPackageReference; relativePath = localPackages/BitLogger; }; A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */ = { isa = XCLocalSwiftPackageReference; relativePath = localPackages/Arti; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1"; requirement = { kind = exactVersion; version = 0.21.1; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ 4EB6BA1B8464F1EA38F4E286 /* P256K */ = { isa = XCSwiftPackageProductDependency; package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; productName = P256K; }; A6E3E56F2E77036A0032EA8A /* BitLogger */ = { isa = XCSwiftPackageProductDependency; productName = BitLogger; }; A6E3E5712E7703760032EA8A /* BitLogger */ = { isa = XCSwiftPackageProductDependency; productName = BitLogger; }; A6E3EA7E2E7706720032EA8A /* Tor */ = { isa = XCSwiftPackageProductDependency; productName = Tor; }; A6E3EA802E7706A80032EA8A /* Tor */ = { isa = XCSwiftPackageProductDependency; productName = Tor; }; B1D9136AA0083366353BFA2F /* P256K */ = { isa = XCSwiftPackageProductDependency; package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; productName = P256K; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 475D96681D0EA0AE57A4E06E /* Project object */; } ================================================ FILE: bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme ================================================ ================================================ FILE: bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (macOS).xcscheme ================================================ ================================================ FILE: bitchatShareExtension/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName bitchat CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType XPC! CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) NSExtension NSExtensionAttributes NSExtensionActivationRule NSExtensionActivationSupportsImageWithMaxCount 1 NSExtensionActivationSupportsText NSExtensionActivationSupportsWebURLWithMaxCount 1 NSExtensionPointIdentifier com.apple.share-services NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).ShareViewController ================================================ FILE: bitchatShareExtension/Localization/Localizable.xcstrings ================================================ { "sourceLanguage": "en", "strings": { "share.fallback.shared_link_title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "shared Link", "comment": "Fallback title when saving a shared link" } }, "ar": { "stringUnit": { "state": "translated", "value": "رابط مشترك", "comment": "Fallback title when saving a shared link" } }, "de": { "stringUnit": { "state": "translated", "value": "geteilter link", "comment": "Fallback title when saving a shared link" } }, "es": { "stringUnit": { "state": "translated", "value": "enlace compartido", "comment": "Fallback title when saving a shared link" } }, "fr": { "stringUnit": { "state": "translated", "value": "lien partagé", "comment": "Fallback title when saving a shared link" } }, "he": { "stringUnit": { "state": "translated", "value": "קישור משותף", "comment": "Fallback title when saving a shared link" } }, "id": { "stringUnit": { "state": "translated", "value": "tautan dibagikan", "comment": "Fallback title when saving a shared link" } }, "it": { "stringUnit": { "state": "translated", "value": "link condiviso", "comment": "Fallback title when saving a shared link" } }, "ja": { "stringUnit": { "state": "translated", "value": "共有リンク", "comment": "Fallback title when saving a shared link" } }, "ne": { "stringUnit": { "state": "translated", "value": "साझा गरिएको लिङ्क", "comment": "Fallback title when saving a shared link" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "link compartilhado", "comment": "Fallback title when saving a shared link" } }, "ru": { "stringUnit": { "state": "translated", "value": "поделился ссылкой", "comment": "Fallback title when saving a shared link" } }, "uk": { "stringUnit": { "state": "translated", "value": "спільне посилання", "comment": "Fallback title when saving a shared link" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "分享的链接", "comment": "Fallback title when saving a shared link" } }, "ko": { "stringUnit": { "state": "translated", "value": "공유된 링크", "comment": "Fallback title when saving a shared link" } }, "tr": { "stringUnit": { "state": "translated", "value": "paylaşılan bağlantı", "comment": "Fallback title when saving a shared link" } } } }, "share.status.failed_to_encode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "failed to encode link", "comment": "Shown when the share payload cannot be encoded" } }, "ar": { "stringUnit": { "state": "translated", "value": "تعذر ترميز الرابط", "comment": "Shown when the share payload cannot be encoded" } }, "de": { "stringUnit": { "state": "translated", "value": "link konnte nicht codiert werden", "comment": "Shown when the share payload cannot be encoded" } }, "es": { "stringUnit": { "state": "translated", "value": "no se pudo codificar el enlace", "comment": "Shown when the share payload cannot be encoded" } }, "fr": { "stringUnit": { "state": "translated", "value": "échec de l'encodage du lien", "comment": "Shown when the share payload cannot be encoded" } }, "he": { "stringUnit": { "state": "translated", "value": "לא ניתן לקודד את הקישור", "comment": "Shown when the share payload cannot be encoded" } }, "id": { "stringUnit": { "state": "translated", "value": "gagal mengodekan tautan", "comment": "Shown when the share payload cannot be encoded" } }, "it": { "stringUnit": { "state": "translated", "value": "impossibile codificare il link", "comment": "Shown when the share payload cannot be encoded" } }, "ja": { "stringUnit": { "state": "translated", "value": "リンクのエンコードに失敗しました", "comment": "Shown when the share payload cannot be encoded" } }, "ne": { "stringUnit": { "state": "translated", "value": "लिङ्क सङ्केत गर्न सकेन", "comment": "Shown when the share payload cannot be encoded" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "falha ao codificar link", "comment": "Shown when the share payload cannot be encoded" } }, "ru": { "stringUnit": { "state": "translated", "value": "не удалось закодировать ссылку", "comment": "Shown when the share payload cannot be encoded" } }, "uk": { "stringUnit": { "state": "translated", "value": "не вдалося закодувати посилання", "comment": "Shown when the share payload cannot be encoded" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法编码链接", "comment": "Shown when the share payload cannot be encoded" } }, "ko": { "stringUnit": { "state": "translated", "value": "링크를 인코딩하는 데 실패했습니다", "comment": "Shown when the share payload cannot be encoded" } }, "tr": { "stringUnit": { "state": "translated", "value": "bağlantı kodlanamadı", "comment": "Shown when the share payload cannot be encoded" } } } }, "share.status.no_shareable_content": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "no shareable content", "comment": "Shown when provided content cannot be shared" } }, "ar": { "stringUnit": { "state": "translated", "value": "لا محتوى قابلاً للمشاركة", "comment": "Shown when provided content cannot be shared" } }, "de": { "stringUnit": { "state": "translated", "value": "kein teilbarer inhalt", "comment": "Shown when provided content cannot be shared" } }, "es": { "stringUnit": { "state": "translated", "value": "sin contenido que se pueda compartir", "comment": "Shown when provided content cannot be shared" } }, "fr": { "stringUnit": { "state": "translated", "value": "aucun contenu partageable", "comment": "Shown when provided content cannot be shared" } }, "he": { "stringUnit": { "state": "translated", "value": "אין תוכן שניתן לשתף", "comment": "Shown when provided content cannot be shared" } }, "id": { "stringUnit": { "state": "translated", "value": "tidak ada konten yang bisa dibagikan", "comment": "Shown when provided content cannot be shared" } }, "it": { "stringUnit": { "state": "translated", "value": "nessun contenuto condivisibile", "comment": "Shown when provided content cannot be shared" } }, "ja": { "stringUnit": { "state": "translated", "value": "共有可能なコンテンツがありません", "comment": "Shown when provided content cannot be shared" } }, "ne": { "stringUnit": { "state": "translated", "value": "बाँड्न मिल्ने सामग्री छैन", "comment": "Shown when provided content cannot be shared" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "nenhum conteúdo compartilhável", "comment": "Shown when provided content cannot be shared" } }, "ru": { "stringUnit": { "state": "translated", "value": "нет подходящего контента", "comment": "Shown when provided content cannot be shared" } }, "uk": { "stringUnit": { "state": "translated", "value": "нема відповідного контенту", "comment": "Shown when provided content cannot be shared" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "没有可分享的素材", "comment": "Shown when provided content cannot be shared" } }, "ko": { "stringUnit": { "state": "translated", "value": "공유할 수 있는 내용이 없습니다", "comment": "Shown when provided content cannot be shared" } }, "tr": { "stringUnit": { "state": "translated", "value": "paylaşılabilir içerik yok", "comment": "Shown when provided content cannot be shared" } } } }, "share.status.nothing_to_share": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "nothing to share", "comment": "Shown when the share extension receives no content" } }, "ar": { "stringUnit": { "state": "translated", "value": "لا شيء لمشاركته", "comment": "Shown when the share extension receives no content" } }, "de": { "stringUnit": { "state": "translated", "value": "nichts zum teilen", "comment": "Shown when the share extension receives no content" } }, "es": { "stringUnit": { "state": "translated", "value": "nada que compartir", "comment": "Shown when the share extension receives no content" } }, "fr": { "stringUnit": { "state": "translated", "value": "rien à partager", "comment": "Shown when the share extension receives no content" } }, "he": { "stringUnit": { "state": "translated", "value": "אין מה לשתף", "comment": "Shown when the share extension receives no content" } }, "id": { "stringUnit": { "state": "translated", "value": "tidak ada yang bisa dibagikan", "comment": "Shown when the share extension receives no content" } }, "it": { "stringUnit": { "state": "translated", "value": "niente da condividere", "comment": "Shown when the share extension receives no content" } }, "ja": { "stringUnit": { "state": "translated", "value": "共有できるものがありません", "comment": "Shown when the share extension receives no content" } }, "ne": { "stringUnit": { "state": "translated", "value": "बाँड्ने केही छैन", "comment": "Shown when the share extension receives no content" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "nada para compartilhar", "comment": "Shown when the share extension receives no content" } }, "ru": { "stringUnit": { "state": "translated", "value": "нечем поделиться", "comment": "Shown when the share extension receives no content" } }, "uk": { "stringUnit": { "state": "translated", "value": "нема чим ділитися", "comment": "Shown when the share extension receives no content" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "没有可分享的内容", "comment": "Shown when the share extension receives no content" } }, "ko": { "stringUnit": { "state": "translated", "value": "공유할 내용이 없습니다", "comment": "Shown when the share extension receives no content" } }, "tr": { "stringUnit": { "state": "translated", "value": "paylaşılacak bir şey yok", "comment": "Shown when the share extension receives no content" } } } }, "share.status.shared_link": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "✓ shared link to bitchat", "comment": "Confirmation after successfully sharing a link" } }, "ar": { "stringUnit": { "state": "translated", "value": "✓ تم إرسال الرابط إلى bitchat", "comment": "Confirmation after successfully sharing a link" } }, "de": { "stringUnit": { "state": "translated", "value": "✓ link zu bitchat geteilt", "comment": "Confirmation after successfully sharing a link" } }, "es": { "stringUnit": { "state": "translated", "value": "✓ enlace compartido con bitchat", "comment": "Confirmation after successfully sharing a link" } }, "fr": { "stringUnit": { "state": "translated", "value": "✓ lien partagé vers bitchat", "comment": "Confirmation after successfully sharing a link" } }, "he": { "stringUnit": { "state": "translated", "value": "✓ הקישור נשלח אל bitchat", "comment": "Confirmation after successfully sharing a link" } }, "id": { "stringUnit": { "state": "translated", "value": "✓ tautan dikirim ke bitchat", "comment": "Confirmation after successfully sharing a link" } }, "it": { "stringUnit": { "state": "translated", "value": "✓ link inviato a bitchat", "comment": "Confirmation after successfully sharing a link" } }, "ja": { "stringUnit": { "state": "translated", "value": "✓ bitchatにリンクを共有", "comment": "Confirmation after successfully sharing a link" } }, "ne": { "stringUnit": { "state": "translated", "value": "✓ bitchat मा लिङ्क पठाइयो", "comment": "Confirmation after successfully sharing a link" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "✓ link enviado para bitchat", "comment": "Confirmation after successfully sharing a link" } }, "ru": { "stringUnit": { "state": "translated", "value": "✓ ссылка отправлена в bitchat", "comment": "Confirmation after successfully sharing a link" } }, "uk": { "stringUnit": { "state": "translated", "value": "✓ посилання надіслано в bitchat", "comment": "Confirmation after successfully sharing a link" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "✓ 已将链接分享至 bitchat", "comment": "Confirmation after successfully sharing a link" } }, "ko": { "stringUnit": { "state": "translated", "value": "✓ bitchat으로 링크를 공유했습니다", "comment": "Confirmation after successfully sharing a link" } }, "tr": { "stringUnit": { "state": "translated", "value": "✓ bitchat'e bağlantı paylaşıldı", "comment": "Confirmation after successfully sharing a link" } } } }, "share.status.shared_text": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "✓ shared text to bitchat", "comment": "Confirmation after successfully sharing text" } }, "ar": { "stringUnit": { "state": "translated", "value": "✓ تم إرسال النص إلى bitchat", "comment": "Confirmation after successfully sharing text" } }, "de": { "stringUnit": { "state": "translated", "value": "✓ text zu bitchat geteilt", "comment": "Confirmation after successfully sharing text" } }, "es": { "stringUnit": { "state": "translated", "value": "✓ texto compartido con bitchat", "comment": "Confirmation after successfully sharing text" } }, "fr": { "stringUnit": { "state": "translated", "value": "✓ texte partagé vers bitchat", "comment": "Confirmation after successfully sharing text" } }, "he": { "stringUnit": { "state": "translated", "value": "✓ הטקסט נשלח אל bitchat", "comment": "Confirmation after successfully sharing text" } }, "id": { "stringUnit": { "state": "translated", "value": "✓ teks dikirim ke bitchat", "comment": "Confirmation after successfully sharing text" } }, "it": { "stringUnit": { "state": "translated", "value": "✓ testo inviato a bitchat", "comment": "Confirmation after successfully sharing text" } }, "ja": { "stringUnit": { "state": "translated", "value": "✓ bitchatにテキストを共有", "comment": "Confirmation after successfully sharing text" } }, "ne": { "stringUnit": { "state": "translated", "value": "✓ bitchat मा पाठ पठाइयो", "comment": "Confirmation after successfully sharing text" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "✓ texto enviado para bitchat", "comment": "Confirmation after successfully sharing text" } }, "ru": { "stringUnit": { "state": "translated", "value": "✓ текст отправлен в bitchat", "comment": "Confirmation after successfully sharing text" } }, "uk": { "stringUnit": { "state": "translated", "value": "✓ текст надіслано в bitchat", "comment": "Confirmation after successfully sharing text" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "✓ 已将文本分享至 bitchat", "comment": "Confirmation after successfully sharing text" } }, "ko": { "stringUnit": { "state": "translated", "value": "✓ bitchat으로 텍스트를 공유했습니다", "comment": "Confirmation after successfully sharing text" } }, "tr": { "stringUnit": { "state": "translated", "value": "✓ bitchat'e metin paylaşıldı", "comment": "Confirmation after successfully sharing text" } } } } }, "version": "1.0" } ================================================ FILE: bitchatShareExtension/ShareViewController.swift ================================================ // // ShareViewController.swift // bitchatShareExtension // // This is free and unencumbered software released into the public domain. // For more information, see // import UIKit import UniformTypeIdentifiers /// Modern share extension using UIKit + UTTypes. /// Avoids deprecated Social framework and SLComposeServiceViewController. final class ShareViewController: UIViewController { // Bundle.main.bundleIdentifier would get the extension's bundleID private static let groupID = "group.chat.bitchat" private enum Strings { static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content") static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared") static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link") static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link") static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text") static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded") } private let statusLabel: UILabel = { let l = UILabel() l.translatesAutoresizingMaskIntoConstraints = false l.font = .systemFont(ofSize: 15, weight: .semibold) l.textAlignment = .center l.numberOfLines = 0 l.textColor = .label return l }() override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground view.addSubview(statusLabel) NSLayoutConstraint.activate([ statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor) ]) DispatchQueue.global().async { self.processShare() } } // MARK: - Processing private func processShare() { guard let ctx = self.extensionContext, let item = ctx.inputItems.first as? NSExtensionItem else { finishWithMessage(Strings.nothingToShare) return } // Try content from attributed text first (Safari often passes URL here) if let url = detectURL(in: item.attributedContentText?.string ?? "") { saveAndFinish(url: url, title: item.attributedTitle?.string) return } // Scan attachments for URL/text let providers = item.attachments ?? [] if providers.isEmpty { // Fallback: use attributed title as plain text if let title = item.attributedTitle?.string, !title.isEmpty { saveAndFinish(text: title) } else { finishWithMessage(Strings.noShareableContent) } return } // Load URL or text asynchronously loadFirstURL(from: providers) { [weak self] url in guard let self = self else { return } if let url = url { self.saveAndFinish(url: url, title: item.attributedTitle?.string) } else { self.loadFirstPlainText(from: providers) { text in if let t = text, !t.isEmpty { // Treat as URL if parseable http(s), else plain text if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") { self.saveAndFinish(url: u, title: item.attributedTitle?.string) } else { self.saveAndFinish(text: t) } } else { self.finishWithMessage(Strings.noShareableContent) } } } } } private func detectURL(in text: String) -> URL? { guard !text.isEmpty else { return nil } let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) let range = NSRange(location: 0, length: (text as NSString).length) let match = detector?.matches(in: text, options: [], range: range).first return match?.url } private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) { let identifiers = [UTType.url.identifier, "public.url", "public.file-url"] let grp = DispatchGroup() var found: URL? for p in providers where found == nil { for id in identifiers where p.hasItemConformingToTypeIdentifier(id) { grp.enter() p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in defer { grp.leave() } if let u = item as? URL { found = u; return } if let s = item as? String, let u = URL(string: s) { found = u; return } if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return } } break } } grp.notify(queue: .main) { completion(found) } } private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) { let id = UTType.plainText.identifier let grp = DispatchGroup() var text: String? for p in providers where p.hasItemConformingToTypeIdentifier(id) { grp.enter() p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in defer { grp.leave() } if let s = item as? String { text = s } else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s } } break } grp.notify(queue: .main) { completion(text) } } // MARK: - Save + Finish private func saveAndFinish(url: URL, title: String?) { let payload: [String: String] = [ "url": url.absoluteString, "title": title ?? url.host ?? Strings.sharedLinkTitleFallback ] if let json = try? JSONSerialization.data(withJSONObject: payload), let s = String(data: json, encoding: .utf8) { saveToSharedDefaults(content: s, type: "url") finishWithMessage(Strings.sharedLinkConfirmation) } else { finishWithMessage(Strings.failedToEncode) } } private func saveAndFinish(text: String) { saveToSharedDefaults(content: text, type: "text") finishWithMessage(Strings.sharedTextConfirmation) } private func saveToSharedDefaults(content: String, type: String) { guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return } userDefaults.set(content, forKey: "sharedContent") userDefaults.set(type, forKey: "sharedContentType") userDefaults.set(Date(), forKey: "sharedContentDate") // No need to force synchronize; the system persists changes } private func finishWithMessage(_ msg: String) { statusLabel.text = msg // Complete shortly after showing status DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) { self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } } } ================================================ FILE: bitchatShareExtension/bitchatShareExtension.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.application-groups group.chat.bitchat ================================================ FILE: bitchatTests/BLEServiceCoreTests.swift ================================================ // // BLEServiceCoreTests.swift // bitchatTests // // Focused BLEService tests for packet handling behavior. // import Testing import Foundation import CoreBluetooth @testable import bitchat struct BLEServiceCoreTests { @Test func duplicatePacket_isDeduped() async { let ble = makeService() let delegate = PublicCaptureDelegate() ble.delegate = delegate let sender = PeerID(str: "1122334455667788") let timestamp = UInt64(Date().timeIntervalSince1970 * 1000) let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp) ble._test_handlePacket(packet, fromPeerID: sender) let receivedFirst = await TestHelpers.waitUntil( { delegate.publicMessagesSnapshot().count == 1 }, timeout: TestConstants.defaultTimeout ) #expect(receivedFirst) ble._test_handlePacket(packet, fromPeerID: sender) let receivedDuplicate = await TestHelpers.waitUntil( { delegate.publicMessagesSnapshot().count > 1 }, timeout: TestConstants.shortTimeout ) #expect(!receivedDuplicate) let messages = delegate.publicMessagesSnapshot() #expect(messages.count == 1) #expect(messages.first?.content == "Hello") } @Test func staleBroadcast_isIgnored() async { let ble = makeService() let delegate = PublicCaptureDelegate() ble.delegate = delegate let sender = PeerID(str: "A1B2C3D4E5F60708") let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000) let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp) ble._test_handlePacket(packet, fromPeerID: sender) let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3) #expect(!didReceive) #expect(delegate.publicMessagesSnapshot().isEmpty) } @Test func announceSenderMismatch_isRejected() async throws { let ble = makeService() let signer = NoiseEncryptionService(keychain: MockKeychain()) let announcement = AnnouncementPacket( nickname: "Spoof", noisePublicKey: signer.getStaticPublicKeyData(), signingPublicKey: signer.getSigningPublicKeyData(), directNeighbors: nil ) let payload = try #require(announcement.encode(), "Failed to encode announcement") let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey) let wrongFirst = derivedPeerID.bare.first == "0" ? "1" : "0" let wrongBare = String(wrongFirst) + String(derivedPeerID.bare.dropFirst()) let wrongPeerID = PeerID(str: wrongBare) let packet = BitchatPacket( type: MessageType.announce.rawValue, senderID: Data(hexString: wrongPeerID.id) ?? Data(), recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: 7 ) let signed = try #require(signer.signPacket(packet), "Failed to sign announce packet") ble._test_handlePacket(signed, fromPeerID: wrongPeerID, preseedPeer: false) _ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3) #expect(ble.currentPeerSnapshots().isEmpty) } } private func makeService() -> BLEService { let keychain = MockKeychain() let identityManager = MockIdentityManager(keychain) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) return BLEService( keychain: keychain, idBridge: idBridge, identityManager: identityManager, initializeBluetoothManagers: false ) } private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket { BitchatPacket( type: MessageType.message.rawValue, senderID: Data(hexString: sender.id) ?? Data(), recipientID: nil, timestamp: timestamp, payload: Data(content.utf8), signature: nil, ttl: 3 ) } private final class PublicCaptureDelegate: BitchatDelegate { private let lock = NSLock() private(set) var publicMessages: [BitchatMessage] = [] func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { let message = BitchatMessage( id: messageID, sender: nickname, content: content, timestamp: timestamp, isRelay: false, originalSender: nil, isPrivate: false, recipientNickname: nil, senderPeerID: peerID, mentions: nil ) lock.lock() publicMessages.append(message) lock.unlock() } func didReceiveMessage(_ message: BitchatMessage) {} func didConnectToPeer(_ peerID: PeerID) {} func didDisconnectFromPeer(_ peerID: PeerID) {} func didUpdatePeerList(_ peers: [PeerID]) {} func didUpdateBluetoothState(_ state: CBManagerState) {} func publicMessagesSnapshot() -> [BitchatMessage] { lock.lock() defer { lock.unlock() } return publicMessages } } ================================================ FILE: bitchatTests/BLEServiceTests.swift ================================================ // // BLEServiceTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Testing import CoreBluetooth @testable import bitchat struct BLEServiceTests { private let service: MockBLEService private let myUUID = UUID() private let bus = MockBLEBus() init() { service = MockBLEService.init(bus: bus) service.myPeerID = PeerID(str: myUUID.uuidString) service.mockNickname = "TestUser" } // MARK: - Basic Functionality Tests @Test func serviceInitialization() { #expect(service.myPeerID == PeerID(str: myUUID.uuidString)) #expect(service.myNickname == "TestUser") } @Test func peerConnection() { let somePeerID = PeerID(str: UUID().uuidString) service.simulateConnectedPeer(somePeerID) #expect(service.isPeerConnected(somePeerID)) #expect(service.getConnectedPeers().count == 1) service.simulateDisconnectedPeer(somePeerID) #expect(!service.isPeerConnected(somePeerID)) #expect(service.getConnectedPeers().count == 0) } @Test func multiplePeerConnections() { let peerID1 = PeerID(str: UUID().uuidString) let peerID2 = PeerID(str: UUID().uuidString) let peerID3 = PeerID(str: UUID().uuidString) service.simulateConnectedPeer(peerID1) service.simulateConnectedPeer(peerID2) service.simulateConnectedPeer(peerID3) #expect(service.getConnectedPeers().count == 3) #expect(service.isPeerConnected(peerID1)) #expect(service.isPeerConnected(peerID2)) #expect(service.isPeerConnected(peerID3)) service.simulateDisconnectedPeer(peerID2) #expect(service.getConnectedPeers().count == 2) #expect(!service.isPeerConnected(peerID2)) } // MARK: - Message Sending Tests @Test func sendPublicMessage() async throws { try await confirmation { receivedPublicMessage in let delegate = MockBitchatDelegate { message in #expect(message.content == "Hello, world!") #expect(message.sender == "TestUser") #expect(!message.isPrivate) receivedPublicMessage() } service.delegate = delegate service.sendMessage("Hello, world!") // Allow async processing try await sleep(1.0) } #expect(service.sentMessages.count == 1) } @Test func sendPrivateMessage() async throws { try await confirmation { receivedPrivateMessage in let delegate = MockBitchatDelegate { message in #expect(message.content == "Secret message") #expect(message.sender == "TestUser") #expect(message.senderPeerID == PeerID(str: myUUID.uuidString)) #expect(message.isPrivate) #expect(message.recipientNickname == "Bob") receivedPrivateMessage() } service.delegate = delegate service.sendPrivateMessage( "Secret message", to: PeerID(str: UUID().uuidString), recipientNickname: "Bob", messageID: "MSG123" ) // Allow async processing try await sleep(1.0) } #expect(service.sentMessages.count == 1) } @Test func sendMessageWithMentions() async throws { try await confirmation { receivedMessageWithMentions in let delegate = MockBitchatDelegate { message in #expect(message.content == "@alice @bob check this out") #expect(message.mentions == ["alice", "bob"]) receivedMessageWithMentions() } service.delegate = delegate service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"]) // Allow async processing try await sleep(1.0) } } // MARK: - Message Reception Tests @Test func simulateIncomingMessage() async throws { try await confirmation { receiveMessage in let peerID = PeerID(str: UUID().uuidString) let delegate = MockBitchatDelegate { message in #expect(message.content == "Incoming message") #expect(message.sender == "RemoteUser") #expect(message.senderPeerID == peerID) receiveMessage() } service.delegate = delegate let incomingMessage = BitchatMessage( id: "MSG456", sender: "RemoteUser", content: "Incoming message", timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: false, recipientNickname: nil, senderPeerID: peerID, mentions: nil ) service.simulateIncomingMessage(incomingMessage) // Allow async processing try await sleep(1.0) } } @Test func simulateIncomingPacket() async throws { try await confirmation { processPacket in let peerID = PeerID(str: UUID().uuidString) let delegate = MockBitchatDelegate { message in #expect(message.content == "Packet message") #expect(message.senderPeerID == peerID) processPacket() } service.delegate = delegate let message = BitchatMessage( id: "MSG789", sender: "PacketSender", content: "Packet message", timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: false, recipientNickname: nil, senderPeerID: peerID, mentions: nil ) let payload = try #require(message.toBinaryPayload(), "Failed to create binary payload") let packet = BitchatPacket( type: 0x01, senderID: peerID.id.data(using: .utf8)!, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: 3 ) service.simulateIncomingPacket(packet) // Allow async processing try await sleep(1.0) } } // MARK: - Peer Nickname Tests @Test func getPeerNicknames() { let peerID1 = PeerID(str: UUID().uuidString) let peerID2 = PeerID(str: UUID().uuidString) service.simulateConnectedPeer(peerID1) service.simulateConnectedPeer(peerID2) let nicknames = service.getPeerNicknames() #expect(nicknames.count == 2) #expect(nicknames[peerID1] == "MockPeer_\(peerID1)") #expect(nicknames[peerID2] == "MockPeer_\(peerID2)") } // MARK: - Service State Tests @Test func startStopServices() { service.startServices() service.stopServices() let somePeerID = PeerID(str: UUID().uuidString) service.simulateConnectedPeer(somePeerID) #expect(service.isPeerConnected(somePeerID)) } // MARK: - Message Delivery Handler Tests @Test func messageDeliveryHandler() async throws { try await confirmation { deliveryHandler in service.packetDeliveryHandler = { packet in if let msg = BitchatMessage(packet.payload) { #expect(msg.content == "Test delivery") deliveryHandler() } } service.sendMessage("Test delivery") // Allow async processing try await sleep(1.0) } } @Test func packetDeliveryHandler() async throws { try await confirmation("Packet handler called") { packetHandler in let peerID = PeerID(str: UUID().uuidString) service.packetDeliveryHandler = { packet in #expect(packet.type == 0x01) #expect(packet.senderID == Data(peerID.id.utf8)) packetHandler() } let message = BitchatMessage( id: "PKT123", sender: "TestSender", content: "Test packet", timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: false, recipientNickname: nil, senderPeerID: peerID, mentions: nil ) let payload = try #require(message.toBinaryPayload(), "Failed to create payload") let packet = BitchatPacket( type: 0x01, senderID: peerID.id.data(using: .utf8)!, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: 3 ) service.simulateIncomingPacket(packet) // Allow async processing try await sleep(1.0) } } } // MARK: - Mock Delegate Helper private final 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: PeerID) {} func didDisconnectFromPeer(_ peerID: PeerID) {} func didUpdatePeerList(_ peers: [PeerID]) {} func isFavorite(fingerprint: String) -> Bool { return false } func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {} func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {} func didUpdateBluetoothState(_ state: CBManagerState) {} func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {} } ================================================ FILE: bitchatTests/BitchatPeerTests.swift ================================================ import Foundation import Testing @testable import bitchat @Suite("BitchatPeer Tests") struct BitchatPeerTests { typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship @Test("Connection state prioritizes bluetooth, mesh, nostr, then offline") func connectionStatePriorityIsCorrect() { let peerID = PeerID(str: "0123456789abcdef") let noiseKey = Data((0..<32).map(UInt8.init)) let mutual = makeRelationship(isFavorite: true, theyFavoritedUs: true) let bluetooth = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: true, isReachable: true) let mesh = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: true) var nostr = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false) nostr.favoriteStatus = mutual let offline = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false) #expect(bluetooth.connectionState == .bluetoothConnected) #expect(mesh.connectionState == .meshReachable) #expect(nostr.connectionState == .nostrAvailable) #expect(offline.connectionState == .offline) } @Test("Display name falls back to peer prefix and offline icon reflects inbound favorite") func displayNameAndOfflineIconUseDerivedState() { let peerID = PeerID(str: "fedcba9876543210") let noiseKey = Data((32..<64).map(UInt8.init)) var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "", isConnected: false, isReachable: false) peer.favoriteStatus = makeRelationship(isFavorite: false, theyFavoritedUs: true) #expect(peer.displayName == String(peerID.id.prefix(8))) #expect(peer.statusIcon == "🌙") } @Test("Mutual offline peers show Nostr icon") func mutualFavoriteOfflinePeerShowsNostrIcon() { let peerID = PeerID(str: "0011223344556677") let noiseKey = Data((64..<96).map(UInt8.init)) var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "Peer", isConnected: false, isReachable: false) peer.favoriteStatus = makeRelationship(isFavorite: true, theyFavoritedUs: true) #expect(peer.statusIcon == "🌐") #expect(peer.isFavorite) #expect(peer.isMutualFavorite) #expect(peer.theyFavoritedUs) } @Test("Equality is based only on peer ID") func equalityUsesPeerIDOnly() { let peerID = PeerID(str: "8899aabbccddeeff") let first = BitchatPeer( peerID: peerID, noisePublicKey: Data(repeating: 1, count: 32), nickname: "First", isConnected: false, isReachable: false ) let second = BitchatPeer( peerID: peerID, noisePublicKey: Data(repeating: 2, count: 32), nickname: "Second", isConnected: true, isReachable: true ) #expect(first == second) } private func makeRelationship(isFavorite: Bool, theyFavoritedUs: Bool) -> FavoriteRelationship { FavoriteRelationship( peerNoisePublicKey: Data(repeating: 7, count: 32), peerNostrPublicKey: "npub1example", peerNickname: "Peer", isFavorite: isFavorite, theyFavoritedUs: theyFavoritedUs, favoritedAt: Date(timeIntervalSince1970: 1), lastUpdated: Date(timeIntervalSince1970: 2) ) } } ================================================ FILE: bitchatTests/ChatViewModelDeliveryStatusTests.swift ================================================ // // ChatViewModelDeliveryStatusTests.swift // bitchatTests // // Tests for ChatViewModel delivery status state machine. // import Testing import Foundation @testable import bitchat // MARK: - Test Helpers @MainActor private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { let keychain = MockKeychain() let keychainHelper = MockKeychainHelper() let idBridge = NostrIdentityBridge(keychain: keychainHelper) let identityManager = MockIdentityManager(keychain) let transport = MockTransport() let viewModel = ChatViewModel( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: transport ) return (viewModel, transport) } // MARK: - Delivery Status Tests struct ChatViewModelDeliveryStatusTests { // MARK: - Status Transition Tests @Test @MainActor func deliveryStatus_noDowngrade_readToDelivered() async { let (viewModel, transport) = makeTestableViewModel() let peerID = PeerID(str: "0102030405060708") let messageID = "test-msg-1" // Setup: create a message with .read status let message = BitchatMessage( id: messageID, sender: viewModel.nickname, content: "Test message", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Peer", senderPeerID: transport.myPeerID, deliveryStatus: .read(by: "Peer", at: Date()) ) viewModel.privateChats[peerID] = [message] // Action: try to downgrade to .delivered viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date())) // Assert: status should remain .read (no downgrade) let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus #expect({ if case .read = currentStatus { return true } return false }()) } @Test @MainActor func deliveryStatus_upgrade_sentToDelivered() async { let (viewModel, transport) = makeTestableViewModel() let peerID = PeerID(str: "0102030405060708") let messageID = "test-msg-2" // Setup: create a message with .sent status let message = BitchatMessage( id: messageID, sender: viewModel.nickname, content: "Test message", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Peer", senderPeerID: transport.myPeerID, deliveryStatus: .sent ) viewModel.privateChats[peerID] = [message] // Action: upgrade to .delivered viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date())) // Assert: status should be .delivered let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus #expect({ if case .delivered = currentStatus { return true } return false }()) } @Test @MainActor func deliveryStatus_upgrade_deliveredToRead() async { let (viewModel, transport) = makeTestableViewModel() let peerID = PeerID(str: "0102030405060708") let messageID = "test-msg-3" // Setup: create a message with .delivered status let message = BitchatMessage( id: messageID, sender: viewModel.nickname, content: "Test message", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Peer", senderPeerID: transport.myPeerID, deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60)) ) viewModel.privateChats[peerID] = [message] // Action: upgrade to .read viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date())) // Assert: status should be .read let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus #expect({ if case .read = currentStatus { return true } return false }()) } // MARK: - Read Receipt Handling @Test @MainActor func didReceiveReadReceipt_updatesMessageStatus() async { let (viewModel, transport) = makeTestableViewModel() let peerID = PeerID(str: "0102030405060708") let messageID = "test-msg-4" // Setup: create a message with .sent status let message = BitchatMessage( id: messageID, sender: viewModel.nickname, content: "Test message", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Peer", senderPeerID: transport.myPeerID, deliveryStatus: .sent ) viewModel.privateChats[peerID] = [message] // Action: receive read receipt let receipt = ReadReceipt( originalMessageID: messageID, readerID: peerID, readerNickname: "Peer" ) viewModel.didReceiveReadReceipt(receipt) // Assert: status should be .read let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus #expect({ if case .read = currentStatus { return true } return false }()) } // MARK: - Public Timeline Status Tests @Test @MainActor func deliveryStatus_publicTimeline_updatesCorrectly() async { let (viewModel, _) = makeTestableViewModel() let messageID = "public-msg-1" // Setup: add a message to public timeline with .sending status let message = BitchatMessage( id: messageID, sender: viewModel.nickname, content: "Public message", timestamp: Date(), isRelay: false, isPrivate: false, deliveryStatus: .sending ) viewModel.messages.append(message) // Action: update to .sent viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent) // Assert let updatedMessage = viewModel.messages.first(where: { $0.id == messageID }) #expect({ if case .sent = updatedMessage?.deliveryStatus { return true } return false }()) } // MARK: - Status Rank Tests (for deduplication) @Test @MainActor func statusRank_orderingIsCorrect() async { // This tests the implicit ordering used in refreshVisibleMessages // failed < sending < sent < partiallyDelivered < delivered < read let statuses: [DeliveryStatus] = [ .failed(reason: "test"), .sending, .sent, .partiallyDelivered(reached: 1, total: 3), .delivered(to: "B", at: Date()), .read(by: "C", at: Date()) ] // Verify each status has a logical progression // This is more of a documentation test to ensure the ranking logic is understood for (index, status) in statuses.enumerated() { switch status { case .failed: #expect(index == 0) case .sending: #expect(index == 1) case .sent: #expect(index == 2) case .partiallyDelivered: #expect(index == 3) case .delivered: #expect(index == 4) case .read: #expect(index == 5) } } } } ================================================ FILE: bitchatTests/ChatViewModelExtensionsTests.swift ================================================ // // ChatViewModelExtensionsTests.swift // bitchatTests // // Tests for ChatViewModel extensions (PrivateChat, Nostr, Tor). // import Testing import Foundation import Combine #if os(iOS) import UIKit #else import AppKit #endif @testable import bitchat // MARK: - Test Helpers @MainActor private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { let keychain = MockKeychain() let keychainHelper = MockKeychainHelper() let idBridge = NostrIdentityBridge(keychain: keychainHelper) let identityManager = MockIdentityManager(keychain) let transport = MockTransport() let viewModel = ChatViewModel( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: transport ) return (viewModel, transport) } // MARK: - Private Chat Extension Tests struct ChatViewModelPrivateChatExtensionTests { @Test @MainActor func sendPrivateMessage_mesh_storesAndSends() async { let (viewModel, transport) = makeTestableViewModel() // Use valid hex string for PeerID (32 bytes = 64 hex chars for Noise key usually, or just valid hex) let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10" let peerID = PeerID(str: validHex) // Simulate connection transport.connectedPeers.insert(peerID) transport.peerNicknames[peerID] = "MeshUser" viewModel.sendPrivateMessage("Hello Mesh", to: peerID) // Verify transport was called // Note: MockTransport stores sent messages // Since sendPrivateMessage delegates to MessageRouter which delegates to Transport... // We need to ensure MessageRouter is using our MockTransport. // ChatViewModel init sets up MessageRouter with the passed transport. // Wait for async processing try? await Task.sleep(nanoseconds: 100_000_000) // Verify message stored locally #expect(viewModel.privateChats[peerID]?.count == 1) #expect(viewModel.privateChats[peerID]?.first?.content == "Hello Mesh") // Verify message sent to transport (MockTransport captures sendPrivateMessage) // MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers // Check MockTransport implementation... it might need update or verification } @Test @MainActor func sendPrivateMessage_unreachable_setsFailedStatus() async { let (viewModel, _) = makeTestableViewModel() let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10" let peerID = PeerID(str: validHex) viewModel.sendPrivateMessage("Hello", to: peerID) #expect(viewModel.privateChats[peerID]?.count == 1) let status = viewModel.privateChats[peerID]?.last?.deliveryStatus #expect({ if case .failed = status { return true } return false }()) } @Test @MainActor func handlePrivateMessage_storesMessage() async { let (viewModel, _) = makeTestableViewModel() let peerID = PeerID(str: "SENDER_001") let message = BitchatMessage( id: "msg-1", sender: "Sender", content: "Private Content", timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: true, recipientNickname: "Me", senderPeerID: peerID ) // Simulate receiving a private message via the handlePrivateMessage extension method viewModel.handlePrivateMessage(message) // Verify stored #expect(viewModel.privateChats[peerID]?.count == 1) #expect(viewModel.privateChats[peerID]?.first?.content == "Private Content") // Verify notification trigger (unread count should increase if not viewing) #expect(viewModel.unreadPrivateMessages.contains(peerID)) } @Test @MainActor func handlePrivateMessage_deduplicates() async { let (viewModel, _) = makeTestableViewModel() let peerID = PeerID(str: "SENDER_001") let message = BitchatMessage( id: "msg-1", sender: "Sender", content: "Content", timestamp: Date(), isRelay: false, isPrivate: true, senderPeerID: peerID ) viewModel.handlePrivateMessage(message) viewModel.handlePrivateMessage(message) // Duplicate #expect(viewModel.privateChats[peerID]?.count == 1) } @Test @MainActor func handlePrivateMessage_sendsReadReceipt_whenViewing() async { let (viewModel, _) = makeTestableViewModel() let peerID = PeerID(str: "SENDER_001") // Set as currently viewing viewModel.selectedPrivateChatPeer = peerID let message = BitchatMessage( id: "msg-1", sender: "Sender", content: "Content", timestamp: Date(), isRelay: false, isPrivate: true, senderPeerID: peerID ) viewModel.handlePrivateMessage(message) // Should NOT be marked unread #expect(!viewModel.unreadPrivateMessages.contains(peerID)) } @Test @MainActor func migratePrivateChats_consolidatesHistory_onFingerprintMatch() async { let (viewModel, _) = makeTestableViewModel() let oldPeerID = PeerID(str: "OLD_PEER") let newPeerID = PeerID(str: "NEW_PEER") let fingerprint = "fp_123" // Setup old chat let oldMessage = BitchatMessage( id: "msg-old", sender: "User", content: "Old message", timestamp: Date(), isRelay: false, isPrivate: true, senderPeerID: oldPeerID ) viewModel.privateChats[oldPeerID] = [oldMessage] viewModel.peerIDToPublicKeyFingerprint[oldPeerID] = fingerprint // Setup new peer fingerprint viewModel.peerIDToPublicKeyFingerprint[newPeerID] = fingerprint // Trigger migration viewModel.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "User") // Verify migration #expect(viewModel.privateChats[newPeerID]?.count == 1) #expect(viewModel.privateChats[newPeerID]?.first?.content == "Old message") #expect(viewModel.privateChats[oldPeerID] == nil) // Old chat removed } @Test @MainActor func isMessageBlocked_filtersBlockedUsers() async { let (viewModel, _) = makeTestableViewModel() let blockedPeerID = PeerID(str: "BLOCKED_PEER") // Block the peer // MockIdentityManager stores state based on fingerprint // We need to map peerID to a fingerprint viewModel.peerIDToPublicKeyFingerprint[blockedPeerID] = "fp_blocked" viewModel.identityManager.setBlocked("fp_blocked", isBlocked: true) // Also ensure UnifiedPeerService can resolve the fingerprint. // UnifiedPeerService uses its own cache or delegates to meshService/Peer list. // Since we are mocking, we can't easily inject into UnifiedPeerService's internal cache. // However, ChatViewModel's isMessageBlocked uses: // 1. isPeerBlocked(peerID) -> unifiedPeerService.isBlocked(peerID) -> getFingerprint -> identityManager.isBlocked // We need UnifiedPeerService.getFingerprint(for: blockedPeerID) to return "fp_blocked" // UnifiedPeerService tries: cache -> meshService -> getPeer // Option 1: Mock the transport (meshService) to return the fingerprint // (viewModel.transport is MockTransport, but UnifiedPeerService holds a reference to it) // Check if MockTransport has `getFingerprint` // If not, we might need to rely on the fallback: ChatViewModel.isMessageBlocked also checks Nostr blocks. // Let's assume MockTransport needs `getFingerprint` implementation or update it. // For now, let's try to verify if `MockTransport` supports `getFingerprint`. // Actually, let's just use the Nostr block path which is simpler and also tested here. // "Check geohash (Nostr) blocks using mapping to full pubkey" let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001" viewModel.nostrKeyMapping[blockedPeerID] = hexPubkey viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true) // Force isGeoChat/isGeoDM check to be true by setting prefix? // Or ensure the logic covers it. // The logic is: // if peerID.isGeoChat || peerID.isGeoDM { check nostr } // We need a peerID that looks like geo. let geoPeerID = PeerID(nostr_: hexPubkey) viewModel.nostrKeyMapping[geoPeerID] = hexPubkey let geoMessage = BitchatMessage( id: "msg-geo-blocked", sender: "BlockedGeoUser", content: "Spam", timestamp: Date(), isRelay: false, isPrivate: true, senderPeerID: geoPeerID ) #expect(viewModel.isMessageBlocked(geoMessage)) } } // MARK: - Nostr Extension Tests struct ChatViewModelNostrExtensionTests { @Test @MainActor func switchLocationChannel_mesh_clearsGeo() async { let (viewModel, _) = makeTestableViewModel() // Setup some geo state viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq"))) #expect(viewModel.currentGeohash == "u4pruydq") // Switch to mesh viewModel.switchLocationChannel(to: .mesh) #expect(viewModel.activeChannel == .mesh) #expect(viewModel.currentGeohash == nil) } @Test @MainActor func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async throws { let geohash = "u4pruydq" let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) LocationChannelManager.shared.select(channel) defer { LocationChannelManager.shared.select(.mesh) } _ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }) let (viewModel, _) = makeTestableViewModel() _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }) let signer = try NostrIdentity.generate() let event = NostrEvent( pubkey: signer.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, tags: [["g", geohash]], content: "Hello Geo" ) let signed = try event.sign(with: signer.schnorrSigningKey()) viewModel.handleNostrEvent(signed) let didAppend = await TestHelpers.waitUntil({ viewModel.publicMessagePipeline.flushIfNeeded() return viewModel.messages.contains { $0.content == "Hello Geo" } }) #expect(didAppend) } @Test @MainActor func handleNostrEvent_ignoresRecentSelfEcho() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash) let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, tags: [["g", geohash]], content: "Self echo" ) let signed = try event.sign(with: identity.schnorrSigningKey()) viewModel.handleNostrEvent(signed) try? await Task.sleep(nanoseconds: 100_000_000) viewModel.publicMessagePipeline.flushIfNeeded() #expect(!viewModel.messages.contains { $0.content == "Self echo" }) } @Test @MainActor func handleNostrEvent_skipsBlockedSender() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" let blockedIdentity = try NostrIdentity.generate() let blockedPubkey = blockedIdentity.publicKeyHex viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true) let event = NostrEvent( pubkey: blockedPubkey, createdAt: Date(), kind: .ephemeralEvent, tags: [["g", geohash]], content: "Blocked" ) let signed = try event.sign(with: blockedIdentity.schnorrSigningKey()) viewModel.handleNostrEvent(signed) try? await Task.sleep(nanoseconds: 100_000_000) viewModel.publicMessagePipeline.flushIfNeeded() #expect(!viewModel.messages.contains { $0.content == "Blocked" }) } @Test @MainActor func handleNostrEvent_rejectsInvalidSignature() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" let identity = try NostrIdentity.generate() viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, tags: [["g", geohash]], content: "Valid" ) var signed = try event.sign(with: identity.schnorrSigningKey()) signed.id = "deadbeef" viewModel.handleNostrEvent(signed) try? await Task.sleep(nanoseconds: 100_000_000) viewModel.publicMessagePipeline.flushIfNeeded() #expect(!viewModel.messages.contains { $0.content == "Tampered" }) } @Test @MainActor func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws { let (viewModel, _) = makeTestableViewModel() let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1) let content = "bitchat1:" + base64URLEncode(oversized) let giftWrap = try NostrProtocol.createPrivateMessage( content: content, recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) viewModel.subscribeGiftWrap(giftWrap, id: recipient) try? await Task.sleep(nanoseconds: 100_000_000) #expect(viewModel.privateChats.isEmpty) } @Test @MainActor func switchLocationChannel_clearsNostrDedupCache() async { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" viewModel.deduplicationService.recordNostrEvent("evt-cache") #expect(viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache")) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) #expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache")) } @Test @MainActor func handleNostrEvent_presenceTracksParticipantWithoutTimelineMessage() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" let identity = try NostrIdentity.generate() viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .geohashPresence, tags: [["g", geohash]], content: "" ) let signed = try event.sign(with: identity.schnorrSigningKey()) viewModel.handleNostrEvent(signed) try? await Task.sleep(nanoseconds: 50_000_000) #expect(viewModel.geohashParticipantCount(for: geohash) >= 1) viewModel.publicMessagePipeline.flushIfNeeded() #expect(viewModel.messages.isEmpty) } @Test @MainActor func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws { let (viewModel, _) = makeTestableViewModel() let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let convKey = PeerID(nostr_: sender.publicKeyHex) let messageID = "geo-ack-delivered" viewModel.privateChats[convKey] = [ BitchatMessage( id: messageID, sender: viewModel.nickname, content: "Hello", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Friend", senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .sent ) ] let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) let giftWrap = try NostrProtocol.createPrivateMessage( content: content, recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) viewModel.subscribeGiftWrap(giftWrap, id: recipient) let didUpdate = await TestHelpers.waitUntil( { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, timeout: 0.5 ) #expect(didUpdate) } @Test @MainActor func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws { let (viewModel, _) = makeTestableViewModel() let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let convKey = PeerID(nostr_: sender.publicKeyHex) let messageID = "geo-ack-read" viewModel.privateChats[convKey] = [ BitchatMessage( id: messageID, sender: viewModel.nickname, content: "Hello", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Friend", senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .delivered(to: "Friend", at: Date()) ) ] let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) let giftWrap = try NostrProtocol.createPrivateMessage( content: content, recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) viewModel.subscribeGiftWrap(giftWrap, id: recipient) let didUpdate = await TestHelpers.waitUntil( { isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, timeout: 0.5 ) #expect(didUpdate) } @Test @MainActor func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws { let (viewModel, _) = makeTestableViewModel() let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let messageID = "gift-private" let convKey = PeerID(nostr_: sender.publicKeyHex) let content = try privateMessageContent( text: "Hello from gift wrap", messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef") ) let giftWrap = try NostrProtocol.createPrivateMessage( content: content, recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) viewModel.handleGiftWrap(giftWrap, id: recipient) let didStore = await TestHelpers.waitUntil( { viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" }, timeout: 0.5 ) #expect(didStore) #expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex) #expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) } @Test @MainActor func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws { let (viewModel, _) = makeTestableViewModel() let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let messageID = "gift-blocked" let convKey = PeerID(nostr_: sender.publicKeyHex) viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true) let content = try privateMessageContent( text: "Blocked", messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef") ) let giftWrap = try NostrProtocol.createPrivateMessage( content: content, recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) viewModel.handleGiftWrap(giftWrap, id: recipient) try? await Task.sleep(nanoseconds: 50_000_000) #expect(viewModel.privateChats[convKey] == nil) #expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) } @Test @MainActor func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws { let (viewModel, _) = makeTestableViewModel() let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let convKey = PeerID(nostr_: sender.publicKeyHex) let messageID = "gift-delivered" viewModel.privateChats[convKey] = [ BitchatMessage( id: messageID, sender: viewModel.nickname, content: "Hello", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Friend", senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .sent ) ] let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) let giftWrap = try NostrProtocol.createPrivateMessage( content: content, recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) viewModel.handleGiftWrap(giftWrap, id: recipient) let didUpdate = await TestHelpers.waitUntil( { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, timeout: 0.5 ) #expect(didUpdate) } @Test @MainActor func findNoiseKey_matchesFavoriteStoredAsNpub() async throws { let (viewModel, _) = makeTestableViewModel() let identity = try NostrIdentity.generate() let noiseKey = Data((0..<32).map { UInt8(($0 + 80) & 0xFF) }) FavoritesPersistenceService.shared.addFavorite( peerNoisePublicKey: noiseKey, peerNostrPublicKey: identity.npub, peerNickname: "Alice" ) defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) } #expect(viewModel.findNoiseKey(for: identity.publicKeyHex) == noiseKey) } @Test @MainActor func findNoiseKey_matchesFavoriteStoredAsHex() async { let (viewModel, _) = makeTestableViewModel() let nostrHex = String(repeating: "ab", count: 32) let noiseKey = Data((0..<32).map { UInt8(($0 + 112) & 0xFF) }) FavoritesPersistenceService.shared.addFavorite( peerNoisePublicKey: noiseKey, peerNostrPublicKey: nostrHex, peerNickname: "Bob" ) defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) } #expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey) } @Test @MainActor func handleFavoriteNotification_updatesFavoriteAssociation() async throws { let (viewModel, _) = makeTestableViewModel() let identity = try NostrIdentity.generate() let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) }) FavoritesPersistenceService.shared.addFavorite( peerNoisePublicKey: noiseKey, peerNostrPublicKey: identity.npub, peerNickname: "Before" ) defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) } viewModel.handleFavoriteNotification( content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice", from: identity.publicKeyHex ) let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) #expect(relationship?.peerNickname == "Alice") #expect(relationship?.peerNostrPublicKey == identity.npub) #expect(relationship?.isFavorite == true) } @Test @MainActor func geohashDMHelpers_exposeMappingAndDisplayName() async { let (viewModel, _) = makeTestableViewModel() let nostrHex = String(repeating: "cd", count: 32) let convKey = PeerID(nostr_: nostrHex) viewModel.geoNicknames[nostrHex] = "Alice" viewModel.startGeohashDM(withPubkeyHex: nostrHex) #expect(viewModel.selectedPrivateChatPeer == convKey) #expect(viewModel.fullNostrHex(forSenderPeerID: convKey) == nostrHex) #expect(viewModel.geohashDisplayName(for: convKey).hasPrefix("Alice")) #expect(viewModel.nostrPubkeyForDisplayName("Alice") == nostrHex) } } // MARK: - Geohash Queue Tests struct ChatViewModelGeohashQueueTests { @Test @MainActor func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" viewModel.addGeohashOnlySystemMessage("Queued system") #expect(!viewModel.messages.contains { $0.content == "Queued system" }) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) #expect(viewModel.messages.contains { $0.content == "Queued system" }) } } // MARK: - GeoDM Tests struct ChatViewModelGeoDMTests { @Test @MainActor func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000001" let messageID = "pm-1" viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash) let convKey = PeerID(nostr_: senderPubkey) let packet = PrivateMessagePacket(messageID: messageID, content: "Hello") let payloadData = try #require(packet.encode(), "Failed to encode private message") let payload = NoisePayload(type: .privateMessage, data: payloadData) viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date()) viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date()) #expect(viewModel.privateChats[convKey]?.count == 1) #expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) } @Test @MainActor func sendGeohashDM_requiresActiveLocationChannel() async { let (viewModel, _) = makeTestableViewModel() let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000001") viewModel.sendGeohashDM("hello", to: convKey) #expect(viewModel.privateChats[convKey] == nil) #expect(viewModel.messages.count == 1) #expect(viewModel.messages.last?.sender == "system") } @Test @MainActor func sendGeohashDM_missingRecipientMapping_marksFailed() async { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000002") viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.sendGeohashDM("hello", to: convKey) #expect(viewModel.privateChats[convKey]?.count == 1) #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus)) } @Test @MainActor func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessage() async { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003" let convKey = PeerID(nostr_: recipientHex) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.nostrKeyMapping[convKey] = recipientHex viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true) viewModel.sendGeohashDM("hello", to: convKey) #expect(viewModel.privateChats[convKey]?.count == 1) #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus)) #expect(viewModel.messages.contains(where: { $0.sender == "system" })) } @Test @MainActor func handlePrivateMessage_geohashViewingConversationRecordsReadReceipt() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000004" let convKey = PeerID(nostr_: senderPubkey) let messageID = "pm-viewing" viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.selectedPrivateChatPeer = convKey let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash) let packet = PrivateMessagePacket(messageID: messageID, content: "Hello") let payloadData = try #require(packet.encode(), "Failed to encode private message") let payload = NoisePayload(type: .privateMessage, data: payloadData) viewModel.handlePrivateMessage( payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date() ) #expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) #expect(viewModel.sentReadReceipts.contains(messageID)) #expect(!viewModel.unreadPrivateMessages.contains(convKey)) } } struct ChatViewModelMediaTransferTests { @Test @MainActor func handleTransferEvent_updatesPrivateMessageProgressAndClearsMappingOnCompletion() async { let (viewModel, _) = makeTestableViewModel() let peerID = PeerID(str: "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10") let message = viewModel.enqueueMediaMessage(content: "[voice] clip.m4a", targetPeer: peerID) let transferID = "transfer-1" viewModel.registerTransfer(transferId: transferID, messageID: message.id) viewModel.handleTransferEvent(.started(id: transferID, totalFragments: 4)) #expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 0, total: 4)) viewModel.handleTransferEvent(.updated(id: transferID, sentFragments: 2, totalFragments: 4)) #expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 2, total: 4)) viewModel.handleTransferEvent(.completed(id: transferID, totalFragments: 4)) #expect(isSent(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id))) #expect(viewModel.messageIDToTransferId[message.id] == nil) #expect(viewModel.transferIdToMessageIDs[transferID] == nil) } @Test @MainActor func handleTransferEvent_cancelledRemovesOutgoingMessage() async { let (viewModel, _) = makeTestableViewModel() let peerID = PeerID(str: "1111111111111111111111111111111111111111111111111111111111111111") let message = viewModel.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: peerID) let transferID = "transfer-2" viewModel.registerTransfer(transferId: transferID, messageID: message.id) viewModel.handleTransferEvent(.cancelled(id: transferID, sentFragments: 1, totalFragments: 3)) #expect(viewModel.privateChats[peerID]?.contains(where: { $0.id == message.id }) != true) #expect(viewModel.messageIDToTransferId[message.id] == nil) } @Test @MainActor func sendVoiceNote_outsideAllowedContextDeletesTempFile() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a") try Data("voice".utf8).write(to: url) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.sendVoiceNote(at: url) #expect(!FileManager.default.fileExists(atPath: url.path)) #expect(viewModel.messages.contains(where: { $0.sender == "system" })) } @Test @MainActor func sendImage_outsideAllowedContextRunsCleanup() async { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" var cleanupCalled = false viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.sendImage(from: URL(fileURLWithPath: "/tmp/ignored.jpg")) { cleanupCalled = true } #expect(cleanupCalled) #expect(viewModel.messages.contains(where: { $0.sender == "system" })) } @Test @MainActor func sendVoiceNote_privateChatUsesPrivateFileTransfer() async throws { let (viewModel, transport) = makeTestableViewModel() let peerID = PeerID(str: "2222222222222222222222222222222222222222222222222222222222222222") let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a") try Data("voice payload".utf8).write(to: url, options: .atomic) defer { try? FileManager.default.removeItem(at: url) } viewModel.selectedPrivateChatPeer = peerID viewModel.sendVoiceNote(at: url) let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 0.5) #expect(didSend) #expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true) #expect(viewModel.messageIDToTransferId.count == 1) #expect(viewModel.transferIdToMessageIDs.count == 1) } @Test @MainActor func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws { let (viewModel, transport) = makeTestableViewModel() let peerID = PeerID(str: "3333333333333333333333333333333333333333333333333333333333333333") let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-too-large-\(UUID().uuidString).m4a") try Data(repeating: 0x55, count: FileTransferLimits.maxVoiceNoteBytes + 1).write(to: url, options: .atomic) viewModel.selectedPrivateChatPeer = peerID viewModel.sendVoiceNote(at: url) let didFail = await TestHelpers.waitUntil({ isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus) }, timeout: 0.5) #expect(didFail) #expect(!FileManager.default.fileExists(atPath: url.path)) #expect(transport.sentPrivateFiles.isEmpty) } @Test @MainActor func sendImage_privateChatProcessesAndTransfersImage() async throws { let (viewModel, transport) = makeTestableViewModel() let peerID = PeerID(str: "4444444444444444444444444444444444444444444444444444444444444444") let sourceURL = try makeTemporaryImageURL() defer { try? FileManager.default.removeItem(at: sourceURL) } viewModel.selectedPrivateChatPeer = peerID viewModel.sendImage(from: sourceURL) let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 1.0) #expect(didSend) #expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg") #expect(viewModel.privateChats[peerID]?.last?.content.contains("[image]") == true) #expect(viewModel.messageIDToTransferId.count == 1) } @Test @MainActor func sendImage_invalidSourceAddsFailureSystemMessage() async throws { let (viewModel, transport) = makeTestableViewModel() let peerID = PeerID(str: "5555555555555555555555555555555555555555555555555555555555555555") let url = FileManager.default.temporaryDirectory.appendingPathComponent("invalid-\(UUID().uuidString).jpg") try Data("not-an-image".utf8).write(to: url, options: .atomic) defer { try? FileManager.default.removeItem(at: url) } viewModel.selectedPrivateChatPeer = peerID viewModel.sendImage(from: url) let didNotify = await TestHelpers.waitUntil({ viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") }) }, timeout: 2.0) #expect(didNotify) #expect(transport.sentPrivateFiles.isEmpty) #expect(viewModel.privateChats[peerID]?.isEmpty != false) } @Test @MainActor func clearTransferMapping_promotesQueuedTransferForSameID() async { let (viewModel, _) = makeTestableViewModel() viewModel.registerTransfer(transferId: "transfer-queue", messageID: "first") viewModel.registerTransfer(transferId: "transfer-queue", messageID: "second") viewModel.clearTransferMapping(for: "first") #expect(viewModel.messageIDToTransferId["first"] == nil) #expect(viewModel.transferIdToMessageIDs["transfer-queue"] == ["second"]) #expect(viewModel.messageIDToTransferId["second"] == "transfer-queue") } @Test @MainActor func cancelMediaSend_cancelsActiveTransferRemovesMessageAndDeletesFile() async throws { let (viewModel, transport) = makeTestableViewModel() let peerID = PeerID(str: "6666666666666666666666666666666666666666666666666666666666666666") let fileName = "cancel-\(UUID().uuidString).m4a" let fileURL = try mediaFileURL(subdirectory: "voicenotes/outgoing", fileName: fileName) try Data("cancel me".utf8).write(to: fileURL, options: .atomic) let message = BitchatMessage( id: "cancel-msg", sender: viewModel.nickname, content: "[voice] \(fileName)", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Peer", senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .sending ) viewModel.privateChats[peerID] = [message] viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id) viewModel.cancelMediaSend(messageID: message.id) #expect(transport.cancelledTransfers == ["transfer-cancel"]) #expect(viewModel.privateChats[peerID] == nil) #expect(!FileManager.default.fileExists(atPath: fileURL.path)) } @Test @MainActor func deleteMediaMessage_removesStoredMessageAndCleansImageFile() async throws { let (viewModel, _) = makeTestableViewModel() let peerID = PeerID(str: "7777777777777777777777777777777777777777777777777777777777777777") let fileName = "delete-\(UUID().uuidString).jpg" let fileURL = try mediaFileURL(subdirectory: "images/outgoing", fileName: fileName) try Data("image bytes".utf8).write(to: fileURL, options: .atomic) let message = BitchatMessage( id: "delete-msg", sender: viewModel.nickname, content: "[image] \(fileName)", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Peer", senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .sent ) viewModel.privateChats[peerID] = [message] viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id) viewModel.deleteMediaMessage(messageID: message.id) #expect(viewModel.privateChats[peerID] == nil) #expect(viewModel.messageIDToTransferId[message.id] == nil) #expect(!FileManager.default.fileExists(atPath: fileURL.path)) } @Test @MainActor func makeTransferID_isPrefixedByMessageIDAndUnique() async { let (viewModel, _) = makeTestableViewModel() let first = viewModel.makeTransferID(messageID: "base") let second = viewModel.makeTransferID(messageID: "base") #expect(first.hasPrefix("base-")) #expect(second.hasPrefix("base-")) #expect(first != second) } } private func base64URLEncode(_ data: Data) -> String { data.base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } private func ackContent(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) throws -> String { if let content = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( type: type, messageID: messageID, senderPeerID: senderPeerID ) { return content } throw ChatViewModelExtensionsTestError.invalidAckContent } private func privateMessageContent(text: String, messageID: String, senderPeerID: PeerID) throws -> String { if let content = NostrEmbeddedBitChat.encodePMForNostrNoRecipient( content: text, messageID: messageID, senderPeerID: senderPeerID ) { return content } throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent } @MainActor private func deliveryStatus(in viewModel: ChatViewModel, peerID: PeerID, messageID: String) -> DeliveryStatus? { viewModel.privateChats[peerID]?.first(where: { $0.id == messageID })?.deliveryStatus } private func isFailed(status: DeliveryStatus?) -> Bool { if case .failed = status { return true } return false } private func isDelivered(status: DeliveryStatus?) -> Bool { if case .delivered = status { return true } return false } private func isRead(status: DeliveryStatus?) -> Bool { if case .read = status { return true } return false } private func isSent(status: DeliveryStatus?) -> Bool { if case .sent = status { return true } return false } private func isPartiallyDelivered(status: DeliveryStatus?, reached: Int, total: Int) -> Bool { if case .partiallyDelivered(let actualReached, let actualTotal) = status { return actualReached == reached && actualTotal == total } return false } private enum ChatViewModelExtensionsTestError: Error { case invalidAckContent case invalidPrivateMessageContent } private func mediaFileURL(subdirectory: String, fileName: String) throws -> URL { let base = try FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true ).appendingPathComponent("files", isDirectory: true) let directory = base.appendingPathComponent(subdirectory, isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) return directory.appendingPathComponent(fileName) } private func makeTemporaryImageURL() throws -> URL { let url = FileManager.default.temporaryDirectory.appendingPathComponent("image-\(UUID().uuidString).png") let data = try makeImageData() try data.write(to: url, options: .atomic) return url } private func makeImageData() throws -> Data { #if os(iOS) let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)).image { context in UIColor.systemTeal.setFill() context.fill(CGRect(x: 0, y: 0, width: 64, height: 64)) } guard let data = image.pngData() else { throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent } return data #else let image = NSImage(size: CGSize(width: 64, height: 64)) image.lockFocus() NSColor.systemTeal.setFill() NSBezierPath(rect: CGRect(x: 0, y: 0, width: 64, height: 64)).fill() image.unlockFocus() guard let tiffData = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData), let data = bitmap.representation(using: .png, properties: [:]) else { throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent } return data #endif } ================================================ FILE: bitchatTests/ChatViewModelRefactoringTests.swift ================================================ // // ChatViewModelRefactoringTests.swift // bitchatTests // // Pinning tests to characterize ChatViewModel behavior before refactoring. // These tests act as a safety net to ensure we don't break existing functionality. // import Testing import Foundation @testable import bitchat struct ChatViewModelRefactoringTests { // Helper to setup the environment @MainActor private func makePinnedViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identity: MockIdentityManager) { let keychain = MockKeychain() let keychainHelper = MockKeychainHelper() let idBridge = NostrIdentityBridge(keychain: keychainHelper) let identityManager = MockIdentityManager(keychain) let transport = MockTransport() let viewModel = ChatViewModel( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: transport ) return (viewModel, transport, identityManager) } // MARK: - Command Processor Integration "Pinning" @Test @MainActor func command_msg_routesToTransport() async throws { let (viewModel, transport, _) = makePinnedViewModel() // Setup: Use simulateConnect so ChatViewModel and UnifiedPeerService are notified let peerID = PeerID(str: "0000000000000001") transport.simulateConnect(peerID, nickname: "alice") let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil }, timeout: TestConstants.shortTimeout) #expect(didResolve) // Action: User types /msg command viewModel.sendMessage("/msg @alice Hello Private World") let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 }, timeout: TestConstants.shortTimeout) #expect(didSend) // Assert: // 1. Should NOT go to public transport #expect(transport.sentMessages.isEmpty, "Command should not be sent as public message") // 2. Should go to private transport logic #expect(transport.sentPrivateMessages.count == 1) #expect(transport.sentPrivateMessages.first?.content == "Hello Private World") #expect(transport.sentPrivateMessages.first?.peerID == peerID) } @Test @MainActor func command_block_updatesIdentity() async throws { let (viewModel, transport, identity) = makePinnedViewModel() // Setup: Use simulateConnect let peerID = PeerID(str: "0000000000000002") // Mock the fingerprint so the block command finds it transport.peerFingerprints[peerID] = "fingerprint_123" transport.simulateConnect(peerID, nickname: "troll") let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil }, timeout: TestConstants.shortTimeout) #expect(didResolve) // Action viewModel.sendMessage("/block @troll") // Assert // Verify identity manager was called to block "fingerprint_123" let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") }, timeout: TestConstants.shortTimeout) #expect(didBlock) } // MARK: - Message Routing Logic @Test @MainActor func routing_incomingPrivateMessage_addsToPrivateChats() async { let (viewModel, _, _) = makePinnedViewModel() let senderID = PeerID(str: "sender_1") // Setup let message = BitchatMessage( id: "msg_1", sender: "bob", content: "Secret", timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: true, recipientNickname: "me", senderPeerID: senderID, mentions: nil ) // Action: Simulate incoming private message viewModel.didReceiveMessage(message) // Wait for async processing with proper timeout let found = await TestHelpers.waitUntil( { viewModel.privateChats[senderID]?.first?.content == "Secret" }, timeout: TestConstants.defaultTimeout ) // Assert #expect(found) } @Test @MainActor func routing_incomingPublicMessage_addsToPublicTimeline() async { let (viewModel, _, _) = makePinnedViewModel() let senderID = PeerID(str: "sender_2") // Action viewModel.didReceivePublicMessage( from: senderID, nickname: "charlie", content: "Public Hi", timestamp: Date(), messageID: "msg_2" ) // Wait for async processing with proper timeout let found = await TestHelpers.waitUntil( { viewModel.timelineStore.messages(for: .mesh).contains(where: { $0.content == "Public Hi" }) }, timeout: TestConstants.defaultTimeout ) // Assert #expect(found) } } ================================================ FILE: bitchatTests/ChatViewModelTests.swift ================================================ // // ChatViewModelTests.swift // bitchatTests // // Tests for ChatViewModel using MockTransport for isolation. // This is free and unencumbered software released into the public domain. // import Testing import Foundation @testable import bitchat // MARK: - Test Helpers /// Creates a ChatViewModel with mock dependencies for testing @MainActor private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { let keychain = MockKeychain() let keychainHelper = MockKeychainHelper() let idBridge = NostrIdentityBridge(keychain: keychainHelper) let identityManager = MockIdentityManager(keychain) let transport = MockTransport() let viewModel = ChatViewModel( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: transport ) return (viewModel, transport) } // MARK: - Initialization Tests struct ChatViewModelInitializationTests { @Test @MainActor func initialization_setsDelegate() async { let (viewModel, transport) = makeTestableViewModel() // The viewModel should set itself as the transport delegate #expect(transport.delegate === viewModel) } @Test @MainActor func initialization_startsServices() async { let (_, transport) = makeTestableViewModel() // Services should be started during init #expect(transport.startServicesCallCount == 1) } @Test @MainActor func initialization_hasEmptyMessageList() async { let (viewModel, _) = makeTestableViewModel() // Initial messages may include system messages, but should be limited #expect(viewModel.messages.count < 10) } @Test @MainActor func initialization_setsNickname() async { let (_, transport) = makeTestableViewModel() // Nickname should be set during init #expect(!transport.myNickname.isEmpty) } } // MARK: - Message Sending Tests struct ChatViewModelSendingTests { @Test @MainActor func sendMessage_delegatesToTransport() async { let (viewModel, transport) = makeTestableViewModel() viewModel.sendMessage("Hello World") #expect(transport.sentMessages.count == 1) #expect(transport.sentMessages.first?.content == "Hello World") } @Test @MainActor func sendMessage_emptyContent_ignored() async { let (viewModel, transport) = makeTestableViewModel() viewModel.sendMessage("") viewModel.sendMessage(" ") viewModel.sendMessage("\n\t") #expect(transport.sentMessages.isEmpty) } @Test @MainActor func sendMessage_withMentions_sendsContent() async { let (viewModel, transport) = makeTestableViewModel() viewModel.sendMessage("Hello @alice") #expect(transport.sentMessages.count == 1) #expect(transport.sentMessages.first?.content == "Hello @alice") } @Test @MainActor func sendMessage_command_notSentToTransport() async { let (viewModel, transport) = makeTestableViewModel() viewModel.sendMessage("/help") // Commands are processed locally, not sent to transport #expect(transport.sentMessages.isEmpty) } } // MARK: - Command Handling Tests struct ChatViewModelCommandTests { @Test @MainActor func sendMessage_commandsNotSentToTransport() async { let (viewModel, transport) = makeTestableViewModel() let commands = ["/nick bob", "/who", "/help", "/clear"] for command in commands { transport.resetRecordings() viewModel.sendMessage(command) try? await Task.sleep(nanoseconds: 100_000_000) #expect(transport.sentMessages.isEmpty) #expect(transport.sentPrivateMessages.isEmpty) } } } // MARK: - Timeline Cap Tests struct ChatViewModelTimelineCapTests { @Test @MainActor func sendMessage_trimsTimelineToCap() async { let (viewModel, _) = makeTestableViewModel() let total = TransportConfig.meshTimelineCap + 5 for i in 0.. (viewModel: ChatViewModel, transport: MockTransport) { let keychain = MockKeychain() let keychainHelper = MockKeychainHelper() let idBridge = NostrIdentityBridge(keychain: keychainHelper) let identityManager = MockIdentityManager(keychain) let transport = MockTransport() let viewModel = ChatViewModel( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: transport ) return (viewModel, transport) } // MARK: - Tor Notification Handler Tests struct ChatViewModelTorTests { // MARK: - handleTorWillStart Tests @Test @MainActor func handleTorWillStart_whenEnforced_setsAnnouncedFlag() async { let (viewModel, _) = makeTestableViewModel() // Precondition: flag should start false #expect(!viewModel.torStatusAnnounced) // Action: simulate Tor starting notification viewModel.handleTorWillStart() // Wait for Task to complete try? await Task.sleep(nanoseconds: 100_000_000) // Assert: flag should be set (torEnforced is true in tests) #expect(viewModel.torStatusAnnounced) } @Test @MainActor func handleTorWillStart_whenAlreadyAnnounced_doesNotDuplicate() async { let (viewModel, _) = makeTestableViewModel() // Setup: pre-set the flag viewModel.torStatusAnnounced = true // Switch to a geohash channel so messages would be visible viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq"))) try? await Task.sleep(nanoseconds: 100_000_000) let initialMessageCount = viewModel.messages.count // Action: call handler again viewModel.handleTorWillStart() try? await Task.sleep(nanoseconds: 100_000_000) // Assert: no new message added (flag was already true) #expect(viewModel.messages.count == initialMessageCount) } // MARK: - handleTorWillRestart Tests @Test @MainActor func handleTorWillRestart_setsPendingFlag() async { let (viewModel, _) = makeTestableViewModel() // Precondition #expect(!viewModel.torRestartPending) // Action viewModel.handleTorWillRestart() try? await Task.sleep(nanoseconds: 100_000_000) // Assert #expect(viewModel.torRestartPending) } @Test @MainActor func handleTorWillRestart_setsFlag_regardlessOfChannel() async { let (viewModel, _) = makeTestableViewModel() // Action: call handler (works regardless of channel) viewModel.handleTorWillRestart() try? await Task.sleep(nanoseconds: 100_000_000) // Assert: flag should be set #expect(viewModel.torRestartPending) } // MARK: - handleTorDidBecomeReady Tests @Test @MainActor func handleTorDidBecomeReady_afterRestart_clearsPendingFlag() async { let (viewModel, _) = makeTestableViewModel() // Setup: simulate restart pending state viewModel.torRestartPending = true // Action viewModel.handleTorDidBecomeReady() try? await Task.sleep(nanoseconds: 100_000_000) // Assert: should clear pending flag #expect(!viewModel.torRestartPending) } @Test @MainActor func handleTorDidBecomeReady_initialStart_setsAnnouncedFlag() async { let (viewModel, _) = makeTestableViewModel() // Setup: not restarting, but initial ready not announced yet viewModel.torRestartPending = false viewModel.torInitialReadyAnnounced = false // Action viewModel.handleTorDidBecomeReady() try? await Task.sleep(nanoseconds: 100_000_000) // Assert: should set flag (torEnforced is true in tests) #expect(viewModel.torInitialReadyAnnounced) } @Test @MainActor func handleTorDidBecomeReady_alreadyAnnounced_noDuplicate() async { let (viewModel, _) = makeTestableViewModel() // Setup: already announced initial ready viewModel.torRestartPending = false viewModel.torInitialReadyAnnounced = true viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq"))) try? await Task.sleep(nanoseconds: 100_000_000) let initialMessageCount = viewModel.messages.count // Action viewModel.handleTorDidBecomeReady() try? await Task.sleep(nanoseconds: 100_000_000) // Assert: no new message #expect(viewModel.messages.count == initialMessageCount) } // MARK: - handleTorPreferenceChanged Tests @Test @MainActor func handleTorPreferenceChanged_resetsAllFlags() async { let (viewModel, _) = makeTestableViewModel() // Setup: set all flags viewModel.torStatusAnnounced = true viewModel.torInitialReadyAnnounced = true viewModel.torRestartPending = true // Action viewModel.handleTorPreferenceChanged(Notification(name: .init("test"))) try? await Task.sleep(nanoseconds: 100_000_000) // Assert: all flags reset #expect(!viewModel.torStatusAnnounced) #expect(!viewModel.torInitialReadyAnnounced) #expect(!viewModel.torRestartPending) } } ================================================ FILE: bitchatTests/CommandProcessorTests.swift ================================================ import Foundation import Testing @testable import bitchat @Suite(.serialized) struct CommandProcessorTests { @MainActor @Test func slapNotFoundGrammar() { let identityManager = MockIdentityManager(MockKeychain()) let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager) let result = processor.process("/slap @system") switch result { case .error(let message): #expect(message == "cannot slap system: not found") default: Issue.record("Expected error result") } } @MainActor @Test func hugNotFoundGrammar() { let identityManager = MockIdentityManager(MockKeychain()) let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager) let result = processor.process("/hug @system") switch result { case .error(let message): #expect(message == "cannot hug system: not found") default: Issue.record("Expected error result") } } @MainActor @Test func slapUsageMessage() { let identityManager = MockIdentityManager(MockKeychain()) let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager) let result = processor.process("/slap") switch result { case .error(let message): #expect(message == "usage: /slap ") default: Issue.record("Expected error result for usage message") } } @MainActor @Test func msgStartsPrivateChatAndSendsMessage() async { let identityManager = MockIdentityManager(MockKeychain()) let context = MockCommandContextProvider() let peerID = PeerID(str: "abcd1234abcd1234") context.nicknameToPeerID["alice"] = peerID let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager) let result = await withSelectedChannel(.mesh) { processor.process("/msg @alice hello there") } switch result { case .success(let message): #expect(message == "started private chat with alice") default: Issue.record("Expected success result") } #expect(context.startedPrivateChats == [peerID]) #expect(context.sentPrivateMessages.count == 1) #expect(context.sentPrivateMessages.first?.content == "hello there") #expect(context.sentPrivateMessages.first?.peerID == peerID) } @MainActor @Test func whoInMeshListsSortedPeerNicknames() async { let identityManager = MockIdentityManager(MockKeychain()) let transport = MockTransport() transport.peerNicknames = [ PeerID(str: "b"): "bob", PeerID(str: "a"): "alice" ] let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager) let result = await withSelectedChannel(.mesh) { processor.process("/who") } switch result { case .success(let message): #expect(message == "online: alice, bob") default: Issue.record("Expected success result") } } @MainActor @Test func whoInGeohashListsVisibleParticipantsExcludingSelf() async throws { let bridge = NostrIdentityBridge(keychain: MockKeychain()) let identityManager = MockIdentityManager(MockKeychain()) let context = MockCommandContextProvider(idBridge: bridge) let geohash = "u4pruy" let selfPubkey = try bridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased() context.visibleGeoParticipants = [ CommandGeoParticipant(id: selfPubkey, displayName: "me"), CommandGeoParticipant(id: String(repeating: "b", count: 64), displayName: "bob") ] let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) let result = await withSelectedChannel(channel) { processor.process("/who") } switch result { case .success(let message): #expect(message == "online: bob") default: Issue.record("Expected success result") } } @MainActor @Test func clearInPrivateChatRemovesOnlySelectedConversation() async { let identityManager = MockIdentityManager(MockKeychain()) let context = MockCommandContextProvider() let activePeer = PeerID(str: "active") let otherPeer = PeerID(str: "other") context.selectedPrivateChatPeer = activePeer context.privateChats = [ activePeer: [makeMessage(sender: "alice", content: "secret")], otherPeer: [makeMessage(sender: "bob", content: "keep")] ] let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager) let result = await withSelectedChannel(.mesh) { processor.process("/clear") } switch result { case .handled: break default: Issue.record("Expected handled result") } #expect(context.privateChats[activePeer] == []) #expect(context.privateChats[otherPeer]?.count == 1) } @MainActor @Test func clearInPublicChatClearsTimeline() async { let identityManager = MockIdentityManager(MockKeychain()) let context = MockCommandContextProvider() let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager) let result = await withSelectedChannel(.mesh) { processor.process("/clear") } switch result { case .handled: break default: Issue.record("Expected handled result") } #expect(context.clearCurrentPublicTimelineCallCount == 1) } @MainActor @Test func hugInPrivateChatSendsPersonalizedMessageAndLocalEcho() async { let identityManager = MockIdentityManager(MockKeychain()) let context = MockCommandContextProvider(nickname: "me") let transport = MockTransport() let peerID = PeerID(str: "abcd1234abcd1234") context.selectedPrivateChatPeer = peerID context.nicknameToPeerID["bob"] = peerID transport.peerNicknames[peerID] = "Bob" let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) let result = await withSelectedChannel(.mesh) { processor.process("/hug @bob") } switch result { case .handled: break default: Issue.record("Expected handled result") } #expect(transport.sentPrivateMessages.count == 1) #expect(transport.sentPrivateMessages.first?.content == "* 🫂 me hugs you *") #expect(context.localPrivateSystemMessages.first?.content == "🫂 you hugged bob") #expect(context.localPrivateSystemMessages.first?.peerID == peerID) } @MainActor @Test func slapInPublicChatSendsPublicRawAndEcho() async { let identityManager = MockIdentityManager(MockKeychain()) let context = MockCommandContextProvider(nickname: "me") let peerID = PeerID(str: "abcd1234abcd1234") context.nicknameToPeerID["bob"] = peerID let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) let result = await withSelectedChannel(.mesh) { processor.process("/slap @bob") } switch result { case .handled: break default: Issue.record("Expected handled result") } #expect(context.sentPublicRawMessages == ["* 🐟 me slaps bob around a bit with a large trout *"]) #expect(context.publicSystemMessages == ["🐟 me slaps bob around a bit with a large trout"]) } @MainActor @Test func blockWithoutArgsListsMeshAndGeohashBlocks() async { let identityManager = MockIdentityManager(MockKeychain()) let context = MockCommandContextProvider() let transport = MockTransport() let peerID = PeerID(str: "abcd1234abcd1234") transport.peerNicknames[peerID] = "bob" transport.peerFingerprints[peerID] = "fp-bob" context.blockedUsers = ["fp-bob"] context.visibleGeoParticipants = [ CommandGeoParticipant(id: String(repeating: "c", count: 64), displayName: "carol") ] identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true) let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) let result = await withSelectedChannel(.mesh) { processor.process("/block") } switch result { case .success(let message): #expect(message == "blocked peers: bob | geohash blocks: carol") default: Issue.record("Expected success result") } } @MainActor @Test func blockAndUnblockMeshPeerUpdateIdentityState() async { let identityManager = MockIdentityManager(MockKeychain()) let context = MockCommandContextProvider() let transport = MockTransport() let peerID = PeerID(str: "abcd1234abcd1234") transport.peerFingerprints[peerID] = "fp-bob" context.nicknameToPeerID["bob"] = peerID let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) let blockResult = await withSelectedChannel(.mesh) { processor.process("/block @bob") } switch blockResult { case .success(let message): #expect(message == "blocked bob. you will no longer receive messages from them") default: Issue.record("Expected success result") } #expect(identityManager.isBlocked(fingerprint: "fp-bob")) let unblockResult = await withSelectedChannel(.mesh) { processor.process("/unblock bob") } switch unblockResult { case .success(let message): #expect(message == "unblocked bob") default: Issue.record("Expected success result") } #expect(!identityManager.isBlocked(fingerprint: "fp-bob")) } @MainActor @Test func blockAndUnblockGeohashPeerUseNostrBlockList() async { let identityManager = MockIdentityManager(MockKeychain()) let context = MockCommandContextProvider() context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64) let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) let blockResult = await withSelectedChannel(.mesh) { processor.process("/block carol") } switch blockResult { case .success(let message): #expect(message == "blocked carol in geohash chats") default: Issue.record("Expected success result") } #expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64))) let unblockResult = await withSelectedChannel(.mesh) { processor.process("/unblock @carol") } switch unblockResult { case .success(let message): #expect(message == "unblocked carol in geohash chats") default: Issue.record("Expected success result") } #expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64))) } @MainActor @Test func favoriteCommandIsRejectedOutsideMesh() async { let identityManager = MockIdentityManager(MockKeychain()) let processor = CommandProcessor( contextProvider: MockCommandContextProvider(), meshService: MockTransport(), identityManager: identityManager ) let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy")) let result = await withSelectedChannel(channel) { processor.process("/fav alice") } switch result { case .error(let message): #expect(message == "favorites are only for mesh peers in #mesh") default: Issue.record("Expected error result") } } @MainActor private func withSelectedChannel(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T { let originalChannel = LocationChannelManager.shared.selectedChannel await setSelectedChannel(channel) do { let result = try work() await setSelectedChannel(originalChannel) return result } catch { await setSelectedChannel(originalChannel) throw error } } @MainActor private func setSelectedChannel(_ channel: ChannelID) async { LocationChannelManager.shared.select(channel) for _ in 0..<40 { if LocationChannelManager.shared.selectedChannel == channel { return } await Task.yield() try? await Task.sleep(nanoseconds: 5_000_000) } } private func makeMessage(sender: String, content: String) -> BitchatMessage { BitchatMessage( sender: sender, content: content, timestamp: Date(timeIntervalSince1970: 1_700_000_000), isRelay: false ) } } @MainActor private final class MockCommandContextProvider: CommandContextProvider { var nickname: String var selectedPrivateChatPeer: PeerID? var blockedUsers: Set = [] var privateChats: [PeerID: [BitchatMessage]] = [:] let idBridge: NostrIdentityBridge var nicknameToPeerID: [String: PeerID] = [:] var visibleGeoParticipants: [CommandGeoParticipant] = [] var displayNameToNostrPubkey: [String: String] = [:] private(set) var startedPrivateChats: [PeerID] = [] private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = [] private(set) var clearCurrentPublicTimelineCallCount = 0 private(set) var sentPublicRawMessages: [String] = [] private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = [] private(set) var publicSystemMessages: [String] = [] private(set) var toggledFavorites: [PeerID] = [] private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] init(nickname: String = "tester", idBridge: NostrIdentityBridge = NostrIdentityBridge(keychain: MockKeychain())) { self.nickname = nickname self.idBridge = idBridge } func getPeerIDForNickname(_ nickname: String) -> PeerID? { nicknameToPeerID[nickname] } func getVisibleGeoParticipants() -> [CommandGeoParticipant] { visibleGeoParticipants } func nostrPubkeyForDisplayName(_ displayName: String) -> String? { displayNameToNostrPubkey[displayName] } func startPrivateChat(with peerID: PeerID) { startedPrivateChats.append(peerID) } func sendPrivateMessage(_ content: String, to peerID: PeerID) { sentPrivateMessages.append((content, peerID)) } func clearCurrentPublicTimeline() { clearCurrentPublicTimelineCallCount += 1 } func sendPublicRaw(_ content: String) { sentPublicRawMessages.append(content) } func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { localPrivateSystemMessages.append((content, peerID)) } func addPublicSystemMessage(_ content: String) { publicSystemMessages.append(content) } func toggleFavorite(peerID: PeerID) { toggledFavorites.append(peerID) } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { favoriteNotifications.append((peerID, isFavorite)) } } ================================================ FILE: bitchatTests/EndToEnd/PrivateChatE2ETests.swift ================================================ // // PrivateChatE2ETests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Testing import CryptoKit import struct Foundation.UUID @testable import bitchat struct PrivateChatE2ETests { private let alice: MockBLEService private let bob: MockBLEService private let charlie: MockBLEService private let mockKeychain = MockKeychain() private let bus = MockBLEBus() init() { // Create services with unique peer IDs to avoid any collision alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus) bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus) charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus) } // MARK: - Basic Private Messaging Tests @Test func simplePrivateMessageShouldNotBeSentWithoutConnection() async { // Intentionally not connecting alice and bob to test var bobReceivedMessage = false await confirmation("Bob should not receive a private message", expectedCount: 0) { bobReceivesMessage in bob.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 && message.isPrivate && message.sender == TestConstants.testNickname1 { bobReceivedMessage = true bobReceivesMessage() } } // Alice sends private message to Bob alice.sendPrivateMessage( TestConstants.testMessage1, to: bob.peerID, recipientNickname: TestConstants.testNickname2 ) // Wait a bit to ensure message would have been delivered if it was going to be try? await sleep(0.1) } #expect(!bobReceivedMessage, "Bob should not have received the message") } @Test func simplePrivateMessage() async { alice.simulateConnection(with: bob) await confirmation("Bob receives private message") { bobReceivesMessage in bob.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 && message.isPrivate && message.sender == TestConstants.testNickname1 { bobReceivesMessage() } } // Alice sends private message to Bob alice.sendPrivateMessage( TestConstants.testMessage1, to: bob.peerID, recipientNickname: TestConstants.testNickname2 ) } } @Test func privateMessageNotReceivedByOthers() async { alice.simulateConnection(with: bob) alice.simulateConnection(with: charlie) await confirmation("Bob receives private message") { bobReceivesMessage in bob.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 && message.isPrivate { bobReceivesMessage() } } charlie.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 { Issue.record("Charlie should not receive") } } alice.sendPrivateMessage( TestConstants.testMessage1, to: bob.peerID, recipientNickname: TestConstants.testNickname2 ) } } // MARK: - End-to-End Encryption Tests @Test func privateMessageEncryption() async { alice.simulateConnection(with: bob) // Setup Noise sessions let aliceKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey() let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Establish encrypted session do { let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID) let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)! let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)! _ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3) } catch { Issue.record("Failed to establish Noise session: \(error)") } await confirmation("Encrypted message received") { receiveEncryptedMessage in // Setup packet handlers for encryption alice.packetDeliveryHandler = { packet in // Encrypt outgoing private messages if packet.type == 0x01, let message = BitchatMessage(packet.payload), message.isPrivate { do { let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID) let encryptedPacket = BitchatPacket( type: 0x02, // Encrypted message type senderID: packet.senderID, recipientID: packet.recipientID, timestamp: packet.timestamp, payload: encrypted, signature: packet.signature, ttl: packet.ttl ) self.bob.simulateIncomingPacket(encryptedPacket) } catch { Issue.record("Encryption failed: \(error)") } } } bob.packetDeliveryHandler = { packet in // Decrypt incoming encrypted messages if packet.type == 0x02 { do { let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID) if let message = BitchatMessage(decrypted) { #expect(message.content == TestConstants.testMessage1) #expect(message.isPrivate) receiveEncryptedMessage() } } catch { Issue.record("Decryption failed: \(error)") } } } // Send encrypted private message alice.sendPrivateMessage( TestConstants.testMessage1, to: bob.peerID, recipientNickname: TestConstants.testNickname2 ) } } // MARK: - Multi-hop Private Message Tests @Test func privateMessageRelay() async { // Setup: Alice -> Bob -> Charlie alice.simulateConnection(with: bob) bob.simulateConnection(with: charlie) await confirmation("Private message relayed to Charlie") { charlieReceivesMessage in // Bob relays private messages for Charlie bob.packetDeliveryHandler = { packet in if let recipientID = packet.recipientID, PeerID(data: recipientID) == charlie.peerID { // Relay to Charlie var relayPacket = packet relayPacket.ttl = packet.ttl - 1 charlie.simulateIncomingPacket(relayPacket) } } charlie.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 && message.isPrivate && message.recipientNickname == TestConstants.testNickname3 { charlieReceivesMessage() } } // Alice sends private message to Charlie (through Bob) alice.sendPrivateMessage( TestConstants.testMessage1, to: charlie.peerID, recipientNickname: TestConstants.testNickname3 ) } } // MARK: - Performance Tests @Test func privateMessageThroughput() async { alice.simulateConnection(with: bob) let messageCount = 100 var receivedCount = 0 await confirmation("All private messages received") { receivePrivateMessage in bob.messageDeliveryHandler = { message in if message.isPrivate && message.sender == TestConstants.testNickname1 { receivedCount += 1 if receivedCount == messageCount { receivePrivateMessage() } } } // Send many private messages for i in 0.. // import Testing import struct Foundation.UUID @testable import bitchat struct PublicChatE2ETests { private let alice: MockBLEService private let bob: MockBLEService private let charlie: MockBLEService private let david: MockBLEService private let bus = MockBLEBus() private var receivedMessages: [String: [BitchatMessage]] = [:] init() { // Create mock services with unique peer IDs to avoid any collision alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus) bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus) charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus) david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4, bus: bus) } // MARK: - Basic Broadcasting Tests @Test func simplePublicMessage() async { alice.simulateConnection(with: bob) await confirmation("Bob receives message") { bobReceivesMessage in bob.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 && message.sender == TestConstants.testNickname1 { bobReceivesMessage() } } alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil) } } @Test func multiRecipientBroadcast() async { alice.simulateConnection(with: bob) alice.simulateConnection(with: charlie) var bobReceivedMessage = false var charlieReceivedMessage = false await confirmation("Both recieve message", expectedCount: 2) { receiveMessage in bob.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 { if !bobReceivedMessage { bobReceivedMessage = true receiveMessage() } else { Issue.record("Bob received more than once") } } } charlie.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 { if !charlieReceivedMessage { charlieReceivedMessage = true receiveMessage() } else { Issue.record("Charlie received more than once") } } } alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil) } } // MARK: - Message Routing and Relay Tests @Test func messageRelayChain() async { // Linear topology: Alice -> Bob -> Charlie alice.simulateConnection(with: bob) bob.simulateConnection(with: charlie) await confirmation("Charlie receives relayed message") { charlieReceivesMessage in // Set up relay in Bob bob.packetDeliveryHandler = { packet in // Bob should relay to Charlie if let message = BitchatMessage(packet.payload), message.sender == TestConstants.testNickname1 { // Create relay message let relayMessage = BitchatMessage( id: message.id, sender: message.sender, content: message.content, timestamp: message.timestamp, isRelay: true, originalSender: message.sender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, senderPeerID: message.senderPeerID, mentions: message.mentions ) if let relayPayload = relayMessage.toBinaryPayload() { let relayPacket = BitchatPacket( type: packet.type, senderID: packet.senderID, recipientID: packet.recipientID, timestamp: packet.timestamp, payload: relayPayload, signature: packet.signature, ttl: packet.ttl - 1 ) // Simulate relay to Charlie self.charlie.simulateIncomingPacket(relayPacket) } } } charlie.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 && message.originalSender == TestConstants.testNickname1 && message.isRelay { charlieReceivesMessage() } } alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil) } } @Test func multiHopRelay() async { // Topology: Alice -> Bob -> Charlie -> David alice.simulateConnection(with: bob) bob.simulateConnection(with: charlie) charlie.simulateConnection(with: david) await confirmation("David receives multi-hop message") { davidReceivesMessage in // Set up relay chain setupRelayHandler(bob, nextHops: [charlie]) setupRelayHandler(charlie, nextHops: [david]) david.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 && message.originalSender == TestConstants.testNickname1 && message.isRelay { davidReceivesMessage() } } alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil) } } // MARK: - TTL (Time To Live) Tests @Test func ttlDecrement() async { // Create a chain longer than TTL let nodes = [alice, bob, charlie, david] // Connect in chain for i in 0.. 0 && i < nodes.count-1 { setupRelayHandler(nodes[i], nextHops: [nodes[i+1]]) } } await confirmation("Message dropped due to TTL", expectedCount: 0) { receiveMessage in david.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 { receiveMessage() // This should not happen } } // Inject at Bob with TTL=2 so Charlie sees it (TTL->1) and does not relay to David let msg = TestHelpers.createTestMessage( content: TestConstants.testMessage1, sender: TestConstants.testNickname1, senderPeerID: alice.peerID ) if let payload = msg.toBinaryPayload() { let pkt = TestHelpers.createTestPacket(senderID: alice.peerID, payload: payload, ttl: 2) bob.simulateIncomingPacket(pkt) } } } @Test func zeroTTLNotRelayed() async { alice.simulateConnection(with: bob) bob.simulateConnection(with: charlie) await confirmation("Zero TTL message not relayed", expectedCount: 0) { receiveMessage in charlie.messageDeliveryHandler = { message in if message.content == "Zero TTL message" { receiveMessage() // Should not happen } } // Create packet with TTL=0 let message = TestHelpers.createTestMessage(content: "Zero TTL message") if let payload = message.toBinaryPayload() { let packet = TestHelpers.createTestPacket(payload: payload, ttl: 0) alice.simulateIncomingPacket(packet) } } } // MARK: - Duplicate Detection Tests @Test func duplicateMessagePrevention() async { alice.simulateConnection(with: bob) var messageCount = 0 await confirmation("Only one message received") { receiveMessage in bob.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 { receiveMessage() messageCount += 1 if messageCount == 1 { // Send duplicate after small delay alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil, messageID: message.id) } else { Issue.record("Duplicate message was not filtered") } } } // Send original message alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil) } } @Test func duplicateContentAsNewMessageNotPrevented() async { alice.simulateConnection(with: bob) var messageCount = 0 await confirmation("Only one message received", expectedCount: 2) { receiveMessage in bob.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 { receiveMessage() messageCount += 1 if messageCount == 1 { // Send the same content as a new message alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil) } } } // Send original message alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil) } } // MARK: - Mention Tests @Test func messageWithMentions() async { alice.simulateConnection(with: bob) alice.simulateConnection(with: charlie) var mentionedUsers: Set = [] await confirmation("Mentioned users receive notification", expectedCount: 2) { receiveMention in bob.messageDeliveryHandler = { message in if message.mentions?.contains(TestConstants.testNickname2) == true { mentionedUsers.insert(TestConstants.testNickname2) receiveMention() } } charlie.messageDeliveryHandler = { message in if message.mentions?.contains(TestConstants.testNickname3) == true { mentionedUsers.insert(TestConstants.testNickname3) receiveMention() } } // Alice mentions Bob and Charlie alice.sendMessage( "Hey @\(TestConstants.testNickname2) and @\(TestConstants.testNickname3)!", mentions: [TestConstants.testNickname2, TestConstants.testNickname3], to: nil ) } #expect(mentionedUsers == [TestConstants.testNickname2, TestConstants.testNickname3]) } // MARK: - Network Topology Tests @Test func meshTopologyBroadcast() async { // Create mesh: Everyone connected to everyone let nodes = [alice, bob, charlie, david] for i in 0.. 0 { node.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 { receiveMessage() } } } alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil) } } @Test func partialMeshRelay() async { // Partial mesh: Alice -> Bob, Bob -> Charlie, Charlie -> David, David -> Alice alice.simulateConnection(with: bob) bob.simulateConnection(with: charlie) charlie.simulateConnection(with: david) david.simulateConnection(with: alice) // Setup relay handlers setupRelayHandler(bob, nextHops: [charlie]) setupRelayHandler(charlie, nextHops: [david]) setupRelayHandler(david, nextHops: [alice]) await confirmation("Message reaches all nodes once", expectedCount: 3) { receiveMessage in for node in [bob, charlie, david] { node.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 { receiveMessage() } } } alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil) } } // MARK: - Performance and Stress Tests @Test func highVolumeMessaging() async { alice.simulateConnection(with: bob) let messageCount = 100 await confirmation("All messages received", expectedCount: messageCount) { receiveMessage in bob.messageDeliveryHandler = { message in if message.sender == TestConstants.testNickname1 { receiveMessage() } } // Send many messages rapidly for i in 0.. 1 else { return } if let message = BitchatMessage(packet.payload) { // Don't relay own messages guard message.senderPeerID != node.peerID else { return } // Create relay message let relayMessage = BitchatMessage( id: message.id, sender: message.sender, content: message.content, timestamp: message.timestamp, isRelay: true, originalSender: message.isRelay ? message.originalSender : message.sender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, senderPeerID: message.senderPeerID, mentions: message.mentions ) if let relayPayload = relayMessage.toBinaryPayload() { let relayPacket = BitchatPacket( type: packet.type, senderID: node.peerID.id.data(using: .utf8)!, recipientID: packet.recipientID, timestamp: packet.timestamp, payload: relayPayload, signature: packet.signature, ttl: packet.ttl - 1 ) // Relay to next hops for nextHop in nextHops { nextHop.simulateIncomingPacket(relayPacket) } } } } } } ================================================ FILE: bitchatTests/Features/ImageUtilsTests.swift ================================================ import Testing import Foundation #if os(iOS) import UIKit #else import AppKit #endif @testable import bitchat private func makeTemporaryFileURL(_ name: String) -> URL { FileManager.default.temporaryDirectory.appendingPathComponent(name) } #if os(iOS) private func makePlatformImage(size: CGSize) -> UIImage { UIGraphicsImageRenderer(size: size).image { context in UIColor.systemTeal.setFill() context.fill(CGRect(origin: .zero, size: size)) } } #else private func makePlatformImage(size: CGSize) -> NSImage { let image = NSImage(size: size) image.lockFocus() NSColor.systemTeal.setFill() NSBezierPath(rect: CGRect(origin: .zero, size: size)).fill() image.unlockFocus() return image } #endif struct ImageUtilsTests { @Test func processImage_rejectsOversizedSourceFile() throws { let url = makeTemporaryFileURL("image-too-large.bin") try Data(repeating: 0xFF, count: 10 * 1024 * 1024 + 1).write(to: url, options: .atomic) defer { try? FileManager.default.removeItem(at: url) } #expect(throws: ImageUtilsError.self) { try ImageUtils.processImage(at: url) } } @Test func processImage_rejectsInvalidImageData() throws { let url = makeTemporaryFileURL("image-invalid.bin") try Data("not-an-image".utf8).write(to: url, options: .atomic) defer { try? FileManager.default.removeItem(at: url) } #expect(throws: ImageUtilsError.self) { try ImageUtils.processImage(at: url) } } @Test func processImage_writesCompressedJpeg() throws { let image = makePlatformImage(size: CGSize(width: 1024, height: 768)) let outputURL = try ImageUtils.processImage(image, maxDimension: 256) defer { try? FileManager.default.removeItem(at: outputURL) } let data = try Data(contentsOf: outputURL) #expect(outputURL.pathExtension.lowercased() == "jpg") #expect(data.starts(with: Data([0xFF, 0xD8]))) #expect(data.count > 0) } } ================================================ FILE: bitchatTests/FontBitchatTests.swift ================================================ import SwiftUI import XCTest @testable import bitchat final class FontBitchatTests: XCTestCase { // func testMonospacedMapping() { // XCTAssertEqual(Font.bitchatSystem(size: 10, design: .monospaced), Font.system(.caption2, design: .monospaced)) // XCTAssertEqual(Font.bitchatSystem(size: 14, design: .monospaced), Font.system(.body, design: .monospaced)) // XCTAssertEqual(Font.bitchatSystem(size: 20, design: .monospaced), Font.system(.title2, design: .monospaced)) // } // // func testWeightIsPreserved() { // let bold = Font.bitchatSystem(size: 14, weight: .bold, design: .monospaced) // XCTAssertEqual(bold, Font.system(.body, design: .monospaced).weight(.bold)) // } } ================================================ FILE: bitchatTests/Fragmentation/FragmentationTests.swift ================================================ // // FragmentationTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Testing import Foundation import CoreBluetooth @testable import bitchat struct FragmentationTests { private let mockKeychain: MockKeychain private let mockIdentityManager: MockIdentityManager private let idBridge: NostrIdentityBridge init() { mockKeychain = MockKeychain() mockIdentityManager = MockIdentityManager(mockKeychain) idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) } @Test("Reassembly from fragments delivers a public message") func reassemblyFromFragmentsDeliversPublicMessage() async throws { let ble = BLEService( keychain: mockKeychain, idBridge: idBridge, identityManager: mockIdentityManager, initializeBluetoothManagers: false ) let capture = CaptureDelegate() ble.delegate = capture // Construct a big packet (3KB) from a remote sender (not our own ID) let remoteShortID = PeerID(str: "1122334455667788") let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000) // Use a small fragment size to ensure multiple pieces let fragments = fragmentPacket(original, fragmentSize: 400) // Shuffle fragments to simulate out-of-order arrival let shuffled = fragments.shuffled() // Send fragments sequentially with small delays (no fire-and-forget Tasks) for (i, fragment) in shuffled.enumerated() { if i > 0 { try await Task.sleep(for: .milliseconds(5)) } ble._test_handlePacket(fragment, fromPeerID: remoteShortID) } // Wait for delegate callback with proper timeout try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2)) #expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.first?.content.count == 3_000) } @Test("Duplicate fragment does not break reassembly") func duplicateFragmentDoesNotBreakReassembly() async throws { let ble = BLEService( keychain: mockKeychain, idBridge: idBridge, identityManager: mockIdentityManager, initializeBluetoothManagers: false ) let capture = CaptureDelegate() ble.delegate = capture let remoteShortID = PeerID(str: "A1B2C3D4E5F60708") let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048) var frags = fragmentPacket(original, fragmentSize: 300) // Duplicate one fragment if let dup = frags.first { frags.insert(dup, at: 1) } // Send fragments sequentially with small delays (no fire-and-forget Tasks) for (i, fragment) in frags.enumerated() { if i > 0 { try await Task.sleep(for: .milliseconds(5)) } ble._test_handlePacket(fragment, fromPeerID: remoteShortID) } // Wait for delegate callback with proper timeout try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2)) #expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.first?.content.count == 2048) } @Test("Max-sized file transfer survives reassembly") func maxSizedFileTransferSurvivesReassembly() async throws { let ble = BLEService( keychain: mockKeychain, idBridge: idBridge, identityManager: mockIdentityManager, initializeBluetoothManagers: false ) let capture = CaptureDelegate() ble.delegate = capture let remoteID = PeerID(str: "CAFEBABECAFEBABE") let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes) let filePacket = BitchatFilePacket( fileName: "limit.bin", fileSize: UInt64(fileContent.count), mimeType: "application/octet-stream", content: fileContent ) let encoded = try #require(filePacket.encode(), "File packet encoding failed") let packet = BitchatPacket( type: MessageType.fileTransfer.rawValue, senderID: Data(hexString: remoteID.id) ?? Data(), recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encoded, signature: nil, ttl: 7, version: 2 ) let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false) #expect(!fragments.isEmpty) for (i, fragment) in fragments.enumerated() { let delay = 5 * Double(i) * 0.001 Task { try await sleep(delay) ble._test_handlePacket(fragment, fromPeerID: remoteID) } } try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2)) let message = try #require(capture.receivedMessages.first, "Expected file transfer message") #expect(message.content.hasPrefix("[file]")) if let fileName = message.content.split(separator: " ").last { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesRoot = base.appendingPathComponent("files", isDirectory: true) let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true) let url = incoming.appendingPathComponent(String(fileName)) try? FileManager.default.removeItem(at: url) } } @Test("Invalid fragment header is ignored") func invalidFragmentHeaderIsIgnored() async throws { let ble = BLEService( keychain: mockKeychain, idBridge: idBridge, identityManager: mockIdentityManager, initializeBluetoothManagers: false ) let capture = CaptureDelegate() ble.delegate = capture let remoteShortID = PeerID(str: "0011223344556677") let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000) let fragments = fragmentPacket(original, fragmentSize: 250) // Corrupt one fragment: make payload too short (header incomplete) var corrupted = fragments if !corrupted.isEmpty { var p = corrupted[0] p = BitchatPacket( type: p.type, senderID: p.senderID, recipientID: p.recipientID, timestamp: p.timestamp, payload: Data([0x00, 0x01, 0x02]), // invalid header signature: nil, ttl: p.ttl ) corrupted[0] = p } for (i, fragment) in corrupted.enumerated() { let delay = 5 * Double(i) * 0.001 Task { try await sleep(delay) ble._test_handlePacket(fragment, fromPeerID: remoteShortID) } } // Allow async processing try await sleep(0.5) // Should not deliver since one fragment is invalid and reassembly can't complete #expect(capture.publicMessages.isEmpty) } } extension FragmentationTests { /// Thread-safe delegate that supports awaiting message delivery private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable { private let lock = NSLock() private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = [] private var _receivedMessages: [BitchatMessage] = [] private var publicMessageContinuation: CheckedContinuation? private var receivedMessageContinuation: CheckedContinuation? private var expectedPublicMessageCount: Int = 0 private var expectedReceivedMessageCount: Int = 0 private func withLock(_ body: () -> T) -> T { lock.lock() defer { lock.unlock() } return body() } var publicMessages: [(peerID: PeerID, nickname: String, content: String)] { withLock { _publicMessages } } var receivedMessages: [BitchatMessage] { withLock { _receivedMessages } } func didReceiveMessage(_ message: BitchatMessage) { lock.lock() _receivedMessages.append(message) let count = _receivedMessages.count let expected = expectedReceivedMessageCount let continuation = receivedMessageContinuation lock.unlock() if count >= expected, let cont = continuation { lock.lock() receivedMessageContinuation = nil lock.unlock() cont.resume() } } func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { lock.lock() _publicMessages.append((peerID, nickname, content)) let count = _publicMessages.count let expected = expectedPublicMessageCount let continuation = publicMessageContinuation lock.unlock() if count >= expected, let cont = continuation { lock.lock() publicMessageContinuation = nil lock.unlock() cont.resume() } } /// Waits for the specified number of public messages to be received func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws { let isAlreadySatisfied = withLock { () -> Bool in if _publicMessages.count >= count { return true } expectedPublicMessageCount = count return false } if isAlreadySatisfied { return } try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { await withCheckedContinuation { continuation in let shouldResumeImmediately = self.withLock { // Recheck count after acquiring lock to avoid race condition // where message arrives between initial check and continuation install if self._publicMessages.count >= count { return true } self.publicMessageContinuation = continuation return false } if shouldResumeImmediately { continuation.resume() } } } group.addTask { try await Task.sleep(for: timeout) throw CancellationError() } try await group.next() group.cancelAll() } } /// Waits for the specified number of received messages func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws { let isAlreadySatisfied = withLock { () -> Bool in if _receivedMessages.count >= count { return true } expectedReceivedMessageCount = count return false } if isAlreadySatisfied { return } try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { await withCheckedContinuation { continuation in let shouldResumeImmediately = self.withLock { // Recheck count after acquiring lock to avoid race condition // where message arrives between initial check and continuation install if self._receivedMessages.count >= count { return true } self.receivedMessageContinuation = continuation return false } if shouldResumeImmediately { continuation.resume() } } } group.addTask { try await Task.sleep(for: timeout) throw CancellationError() } try await group.next() group.cancelAll() } } func didConnectToPeer(_ peerID: PeerID) {} func didDisconnectFromPeer(_ peerID: PeerID) {} func didUpdatePeerList(_ peers: [PeerID]) {} func isFavorite(fingerprint: String) -> Bool { false } func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {} func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {} func didUpdateBluetoothState(_ state: CBManagerState) {} func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {} } // Helper: build a large message packet (unencrypted public message) private func makeLargePublicPacket(senderShortHex: PeerID, size: Int) -> BitchatPacket { let content = String(repeating: "A", count: size) let payload = Data(content.utf8) let pkt = BitchatPacket( type: MessageType.message.rawValue, senderID: Data(hexString: senderShortHex.id) ?? Data(), recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: 7 ) return pkt } // Helper: fragment a packet using the same header format BLEService expects private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] { guard let fullData = packet.toBinaryData(padding: pad) else { return [] } let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) }) let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in Data(fullData[off..= 1) let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data) #expect(decoded.count <= 1) } @Test func bucketAvoidsZeroCandidate() { let id = Data(repeating: 0x01, count: 16) let bucket = GCSFilter.bucket(for: id, modulus: 2) #expect(bucket != 0) #expect(bucket < 2) } } ================================================ FILE: bitchatTests/GeohashBookmarksStoreTests.swift ================================================ import Testing import Foundation @testable import bitchat struct GeohashBookmarksStoreTests { private let storeKey = "locationChannel.bookmarks" private let storage = UserDefaults(suiteName: UUID().uuidString)! private let store: GeohashBookmarksStore init() { store = GeohashBookmarksStore(storage: storage) } @Test func toggleAndNormalize() { // Start clean #expect(store.bookmarks.isEmpty) // Add with mixed case and hash prefix store.toggle("#U4PRUY") #expect(store.isBookmarked("u4pruy")) #expect(store.bookmarks.first == "u4pruy") // Toggling again removes store.toggle("u4pruy") #expect(!store.isBookmarked("u4pruy")) #expect(store.bookmarks.isEmpty) } @Test func persistenceWritten() throws { store.toggle("ezs42") store.toggle("u4pruy") // Verify persisted JSON contains both (order not enforced here) let data = try #require(storage.data(forKey: storeKey), "No persisted data found") let arr = try JSONDecoder().decode([String].self, from: data) #expect(arr.contains("ezs42")) #expect(arr.contains("u4pruy")) } } ================================================ FILE: bitchatTests/GeohashParticipantTrackerTests.swift ================================================ // // GeohashParticipantTrackerTests.swift // bitchatTests // // Tests for GeohashParticipantTracker. // This is free and unencumbered software released into the public domain. // import Testing import Foundation @testable import bitchat /// Mock context for testing @MainActor final class MockParticipantContext: GeohashParticipantContext { var blockedPubkeys: Set = [] var nicknameMap: [String: String] = [:] var selfPubkey: String? func displayNameForPubkey(_ pubkeyHex: String) -> String { let suffix = String(pubkeyHex.suffix(4)) if let self = selfPubkey, pubkeyHex.lowercased() == self.lowercased() { return "me#\(suffix)" } if let nick = nicknameMap[pubkeyHex.lowercased()] { return "\(nick)#\(suffix)" } return "anon#\(suffix)" } func isBlocked(_ pubkeyHexLowercased: String) -> Bool { blockedPubkeys.contains(pubkeyHexLowercased.lowercased()) } } @MainActor struct GeohashParticipantTrackerTests { // MARK: - Basic Recording Tests @Test func recordParticipant_addsToActiveGeohash() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) tracker.setActiveGeohash("abc123") tracker.recordParticipant(pubkeyHex: "deadbeef1234") #expect(tracker.participantCount(for: "abc123") == 1) } @Test func recordParticipant_noActiveGeohash_noOp() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) // No active geohash set tracker.recordParticipant(pubkeyHex: "deadbeef1234") // Should not throw or crash #expect(tracker.participantCount(for: "abc123") == 0) } @Test func recordParticipant_specificGeohash() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1") tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2") #expect(tracker.participantCount(for: "geo1") == 1) #expect(tracker.participantCount(for: "geo2") == 1) } @Test func recordParticipant_updatesLastSeen() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) tracker.setActiveGeohash("abc123") tracker.recordParticipant(pubkeyHex: "pubkey1") // Small delay and record again try? await Task.sleep(nanoseconds: 10_000_000) // 10ms tracker.recordParticipant(pubkeyHex: "pubkey1") // Should still count as 1 participant (updated, not duplicated) #expect(tracker.participantCount(for: "abc123") == 1) } @Test func recordParticipant_lowercasesPubkey() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) tracker.setActiveGeohash("abc123") tracker.recordParticipant(pubkeyHex: "DEADBEEF") tracker.recordParticipant(pubkeyHex: "deadbeef") // Should be treated as same participant #expect(tracker.participantCount(for: "abc123") == 1) } // MARK: - Visible People Tests @Test func getVisiblePeople_returnsActiveGeohashParticipants() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) tracker.setActiveGeohash("abc123") tracker.recordParticipant(pubkeyHex: "pubkey1") tracker.recordParticipant(pubkeyHex: "pubkey2") let people = tracker.getVisiblePeople() #expect(people.count == 2) } @Test func getVisiblePeople_excludesBlockedParticipants() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() context.blockedPubkeys = ["pubkey2"] tracker.configure(context: context) tracker.setActiveGeohash("abc123") tracker.recordParticipant(pubkeyHex: "pubkey1") tracker.recordParticipant(pubkeyHex: "pubkey2") let people = tracker.getVisiblePeople() #expect(people.count == 1) #expect(people.first?.id == "pubkey1") } @Test func getVisiblePeople_usesDisplayNameFromContext() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() context.nicknameMap = ["pubkey1234": "alice"] tracker.configure(context: context) tracker.setActiveGeohash("abc123") tracker.recordParticipant(pubkeyHex: "pubkey1234") let people = tracker.getVisiblePeople() #expect(people.count == 1) #expect(people.first?.displayName == "alice#1234") } @Test func getVisiblePeople_sortedByLastSeen() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) tracker.setActiveGeohash("abc123") tracker.recordParticipant(pubkeyHex: "older") try? await Task.sleep(nanoseconds: 10_000_000) // 10ms tracker.recordParticipant(pubkeyHex: "newer") let people = tracker.getVisiblePeople() #expect(people.count == 2) #expect(people.first?.id == "newer") #expect(people.last?.id == "older") } @Test func getVisiblePeople_emptyWhenNoActiveGeohash() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123") let people = tracker.getVisiblePeople() #expect(people.isEmpty) } // MARK: - Activity Cutoff Tests @Test func participantCount_excludesExpiredEntries() async { // Use a very short cutoff for testing let tracker = GeohashParticipantTracker(activityCutoff: -0.05) // 50ms cutoff let context = MockParticipantContext() tracker.configure(context: context) tracker.setActiveGeohash("abc123") tracker.recordParticipant(pubkeyHex: "pubkey1") // Should be counted immediately #expect(tracker.participantCount(for: "abc123") == 1) // Wait for expiry try? await Task.sleep(nanoseconds: 100_000_000) // 100ms // Should be expired now #expect(tracker.participantCount(for: "abc123") == 0) } // MARK: - Remove Participant Tests @Test func removeParticipant_removesFromAllGeohashes() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1") tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo2") tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo1") tracker.removeParticipant(pubkeyHex: "pubkey1") #expect(tracker.participantCount(for: "geo1") == 1) #expect(tracker.participantCount(for: "geo2") == 0) } // MARK: - Clear Tests @Test func clear_removesAllData() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) tracker.setActiveGeohash("abc123") tracker.recordParticipant(pubkeyHex: "pubkey1") tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "other") tracker.clear() #expect(tracker.participantCount(for: "abc123") == 0) #expect(tracker.participantCount(for: "other") == 0) #expect(tracker.visiblePeople.isEmpty) } @Test func clearGeohash_removesOnlySpecificGeohash() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1") tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2") tracker.clear(geohash: "geo1") #expect(tracker.participantCount(for: "geo1") == 0) #expect(tracker.participantCount(for: "geo2") == 1) } // MARK: - Set Active Geohash Tests @Test func setActiveGeohash_clearsVisiblePeopleWhenNil() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) tracker.setActiveGeohash("abc123") tracker.recordParticipant(pubkeyHex: "pubkey1") #expect(!tracker.visiblePeople.isEmpty) tracker.setActiveGeohash(nil) #expect(tracker.visiblePeople.isEmpty) } @Test func setActiveGeohash_refreshesVisiblePeople() async { let tracker = GeohashParticipantTracker() let context = MockParticipantContext() tracker.configure(context: context) // Pre-populate a geohash tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123") // Set it as active tracker.setActiveGeohash("abc123") #expect(tracker.visiblePeople.count == 1) } // MARK: - GeoPerson Tests @Test func geoPerson_identifiable() async { let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date()) let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date()) let person3 = GeoPerson(id: "xyz", displayName: "bob", lastSeen: Date()) #expect(person1.id == person2.id) #expect(person1.id != person3.id) } @Test func geoPerson_equatable() async { let date = Date() let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date) let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date) #expect(person1 == person2) } } ================================================ FILE: bitchatTests/GeohashPresenceTests.swift ================================================ // // GeohashPresenceTests.swift // bitchatTests // // Tests for the Geohash Presence (Kind 20001) feature. // This is free and unencumbered software released into the public domain. // import Testing import Foundation import Combine @testable import bitchat // MARK: - NostrProtocol Presence Event Tests struct NostrProtocolPresenceTests { @Test func createGeohashPresenceEvent_hasCorrectKind() throws { let identity = try makeTestIdentity() let event = try NostrProtocol.createGeohashPresenceEvent( geohash: "u4pruydq", senderIdentity: identity ) #expect(event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) #expect(event.kind == 20001) } @Test func createGeohashPresenceEvent_hasEmptyContent() throws { let identity = try makeTestIdentity() let event = try NostrProtocol.createGeohashPresenceEvent( geohash: "u4pruydq", senderIdentity: identity ) #expect(event.content == "") } @Test func createGeohashPresenceEvent_hasOnlyGeohashTag() throws { let identity = try makeTestIdentity() let event = try NostrProtocol.createGeohashPresenceEvent( geohash: "u4pruydq", senderIdentity: identity ) // Should have exactly one tag: ["g", geohash] #expect(event.tags.count == 1) #expect(event.tags[0] == ["g", "u4pruydq"]) } @Test func createGeohashPresenceEvent_noNicknameTag() throws { let identity = try makeTestIdentity() let event = try NostrProtocol.createGeohashPresenceEvent( geohash: "u4pruydq", senderIdentity: identity ) // Should NOT contain nickname tag let hasNicknameTag = event.tags.contains { $0.first == "n" } #expect(!hasNicknameTag) } @Test func createGeohashPresenceEvent_usesSenderPubkey() throws { let identity = try makeTestIdentity() let event = try NostrProtocol.createGeohashPresenceEvent( geohash: "u4pruydq", senderIdentity: identity ) #expect(event.pubkey == identity.publicKeyHex) } @Test func createGeohashPresenceEvent_isSigned() throws { let identity = try makeTestIdentity() let event = try NostrProtocol.createGeohashPresenceEvent( geohash: "u4pruydq", senderIdentity: identity ) #expect(event.sig != nil && !event.sig!.isEmpty) #expect(!event.id.isEmpty) } @Test func createGeohashPresenceEvent_differentGeohashes() throws { let identity = try makeTestIdentity() let event1 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87", senderIdentity: identity) let event2 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw", senderIdentity: identity) let event3 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw7", senderIdentity: identity) #expect(event1.tags[0][1] == "87") #expect(event2.tags[0][1] == "87yw") #expect(event3.tags[0][1] == "87yw7") } // MARK: - Helper private func makeTestIdentity() throws -> NostrIdentity { // Generate a fresh test identity return try NostrIdentity.generate() } } // MARK: - NostrFilter Presence Tests struct NostrFilterPresenceTests { @Test func geohashEphemeral_includesBothKinds() { let filter = NostrFilter.geohashEphemeral("u4pruydq") #expect(filter.kinds?.contains(20000) == true) #expect(filter.kinds?.contains(20001) == true) } @Test func geohashEphemeral_hasLimit1000() { let filter = NostrFilter.geohashEphemeral("u4pruydq") #expect(filter.limit == 1000) } @Test func geohashEphemeral_respectsSinceParameter() { let since = Date(timeIntervalSince1970: 1700000000) let filter = NostrFilter.geohashEphemeral("u4pruydq", since: since) #expect(filter.since == 1700000000) } } // MARK: - ChatViewModel Presence Handling Tests @MainActor struct ChatViewModelPresenceHandlingTests { @Test func handleNostrEvent_presenceUpdatesParticipantTracker() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" // Set up the channel viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) // Create a presence event (kind 20001) let identity = try NostrIdentity.generate() let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .geohashPresence, tags: [["g", geohash]], content: "" ) let signed = try event.sign(with: identity.schnorrSigningKey()) // Handle the event viewModel.handleNostrEvent(signed) // Allow async processing try? await Task.sleep(nanoseconds: 50_000_000) // Participant should be recorded let count = viewModel.geohashParticipantCount(for: geohash) #expect(count >= 1) } @Test func handleNostrEvent_presenceDoesNotAddToTimeline() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) let initialMessageCount = viewModel.messages.count // Create a presence event (kind 20001) let identity = try NostrIdentity.generate() let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .geohashPresence, tags: [["g", geohash]], content: "" ) let signed = try event.sign(with: identity.schnorrSigningKey()) viewModel.handleNostrEvent(signed) try? await Task.sleep(nanoseconds: 50_000_000) // Message count should NOT increase #expect(viewModel.messages.count == initialMessageCount) } @Test func handleNostrEvent_chatMessageUpdatesParticipant() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) // Create a chat event (kind 20000) - NOT presence let identity = try NostrIdentity.generate() let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, tags: [["g", geohash]], content: "Hello world" ) let signed = try event.sign(with: identity.schnorrSigningKey()) viewModel.handleNostrEvent(signed) try? await Task.sleep(nanoseconds: 50_000_000) // Chat messages should also update participant count (not just presence) let count = viewModel.geohashParticipantCount(for: geohash) #expect(count >= 1) } @Test func presenceEvent_hasDifferentKindThanChat() { // Verify the two event kinds are distinct let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue #expect(presenceKind != chatKind) #expect(presenceKind == 20001) #expect(chatKind == 20000) } @Test func subscribeNostrEvent_acceptsPresenceKind() async throws { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) // Create presence event let identity = try NostrIdentity.generate() let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .geohashPresence, tags: [["g", geohash]], content: "" ) let signed = try event.sign(with: identity.schnorrSigningKey()) // subscribeNostrEvent should accept kind 20001 viewModel.subscribeNostrEvent(signed) try? await Task.sleep(nanoseconds: 50_000_000) // Should record participant let count = viewModel.geohashParticipantCount(for: geohash) #expect(count >= 1) } @Test func subscribeNostrEvent_presenceForNonActiveGeohash() async throws { let (viewModel, _) = makeTestableViewModel() let activeGeohash = "u4pruydq" let otherGeohash = "87yw7" viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash))) // Create presence event for a DIFFERENT geohash let identity = try NostrIdentity.generate() let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .geohashPresence, tags: [["g", otherGeohash]], content: "" ) let signed = try event.sign(with: identity.schnorrSigningKey()) // Use subscribeNostrEvent with geohash parameter viewModel.subscribeNostrEvent(signed, gh: otherGeohash) try? await Task.sleep(nanoseconds: 50_000_000) // Should record for the other geohash let count = viewModel.geohashParticipantCount(for: otherGeohash) #expect(count >= 1) } // MARK: - Test Helper private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { let keychain = MockKeychain() let keychainHelper = MockKeychainHelper() let idBridge = NostrIdentityBridge(keychain: keychainHelper) let identityManager = MockIdentityManager(keychain) let transport = MockTransport() let viewModel = ChatViewModel( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: transport ) return (viewModel, transport) } } // MARK: - Presence Privacy Tests struct GeohashPresencePrivacyTests { @Test func allowedPrecisions_onlyLowPrecision() { // The allowed precisions for presence broadcasting should be: // Region (2), Province (4), City (5) // NOT Neighborhood (6), Block (7), Building (8+) let regionPrecision = GeohashChannelLevel.region.precision let provincePrecision = GeohashChannelLevel.province.precision let cityPrecision = GeohashChannelLevel.city.precision let neighborhoodPrecision = GeohashChannelLevel.neighborhood.precision let blockPrecision = GeohashChannelLevel.block.precision let buildingPrecision = GeohashChannelLevel.building.precision #expect(regionPrecision == 2) #expect(provincePrecision == 4) #expect(cityPrecision == 5) #expect(neighborhoodPrecision == 6) #expect(blockPrecision == 7) #expect(buildingPrecision == 8) // High precision channels should NOT receive presence broadcasts #expect(neighborhoodPrecision > 5) #expect(blockPrecision > 5) #expect(buildingPrecision > 5) } @Test func geohashLengthDeterminesPrecision() { // Verify geohash length maps to expected precision #expect("87".count == GeohashChannelLevel.region.precision) #expect("87yw".count == GeohashChannelLevel.province.precision) #expect("87yw7".count == GeohashChannelLevel.city.precision) #expect("87yw7t".count == GeohashChannelLevel.neighborhood.precision) #expect("87yw7tc".count == GeohashChannelLevel.block.precision) #expect("87yw7tcx".count == GeohashChannelLevel.building.precision) } @Test func highPrecisionGeohash_isPrivacySensitive() { // Helper to check if a geohash is "high precision" (privacy sensitive) func isHighPrecision(_ geohash: String) -> Bool { geohash.count >= 6 } // Low precision - OK to broadcast presence #expect(!isHighPrecision("87")) // region #expect(!isHighPrecision("87yw")) // province #expect(!isHighPrecision("87yw7")) // city // High precision - should NOT broadcast presence #expect(isHighPrecision("87yw7t")) // neighborhood #expect(isHighPrecision("87yw7tc")) // block #expect(isHighPrecision("87yw7tcx")) // building } } // MARK: - Display Logic Tests struct LocationChannelsDisplayLogicTests { @Test func displayLogic_highPrecisionZeroCount_showsUnknown() { // Test the logic that determines "?" vs actual count // High precision + count 0 = "?" let shouldShowUnknown = shouldShowUnknownCount( level: .neighborhood, count: 0 ) #expect(shouldShowUnknown) } @Test func displayLogic_highPrecisionNonZeroCount_showsActual() { // High precision + count > 0 = show actual let shouldShowUnknown = shouldShowUnknownCount( level: .neighborhood, count: 5 ) #expect(!shouldShowUnknown) } @Test func displayLogic_lowPrecisionZeroCount_showsActual() { // Low precision + count 0 = show "0" (not "?") let shouldShowUnknown = shouldShowUnknownCount( level: .city, count: 0 ) #expect(!shouldShowUnknown) } @Test func displayLogic_lowPrecisionNonZeroCount_showsActual() { // Low precision + count > 0 = show actual let shouldShowUnknown = shouldShowUnknownCount( level: .region, count: 10 ) #expect(!shouldShowUnknown) } @Test func displayLogic_allHighPrecisionLevels() { // All high precision levels with 0 should show "?" let highPrecisionLevels: [GeohashChannelLevel] = [.neighborhood, .block, .building] for level in highPrecisionLevels { let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0) #expect(shouldShowUnknown, "Level \(level) with count 0 should show unknown") } } @Test func displayLogic_allLowPrecisionLevels() { // All low precision levels with 0 should show actual count let lowPrecisionLevels: [GeohashChannelLevel] = [.region, .province, .city] for level in lowPrecisionLevels { let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0) #expect(!shouldShowUnknown, "Level \(level) with count 0 should show actual count") } } @Test func displayLogic_bookmarkHighPrecision() { // Bookmarks use geohash length to determine precision #expect(shouldShowUnknownForBookmark(geohash: "87yw7t", count: 0)) // len 6 #expect(shouldShowUnknownForBookmark(geohash: "87yw7tc", count: 0)) // len 7 #expect(shouldShowUnknownForBookmark(geohash: "87yw7tcx", count: 0)) // len 8 } @Test func displayLogic_bookmarkLowPrecision() { #expect(!shouldShowUnknownForBookmark(geohash: "87", count: 0)) // len 2 #expect(!shouldShowUnknownForBookmark(geohash: "87yw", count: 0)) // len 4 #expect(!shouldShowUnknownForBookmark(geohash: "87yw7", count: 0)) // len 5 } // MARK: - Helpers (mirror the logic from LocationChannelsSheet) private func shouldShowUnknownCount(level: GeohashChannelLevel, count: Int) -> Bool { let isHighPrecision = (level == .neighborhood || level == .block || level == .building) return isHighPrecision && count == 0 } private func shouldShowUnknownForBookmark(geohash: String, count: Int) -> Bool { let isHighPrecision = (geohash.count >= 6) return isHighPrecision && count == 0 } } // MARK: - Event Kind Tests struct NostrEventKindTests { @Test func eventKind_geohashPresence_is20001() { #expect(NostrProtocol.EventKind.geohashPresence.rawValue == 20001) } @Test func eventKind_ephemeralEvent_is20000() { #expect(NostrProtocol.EventKind.ephemeralEvent.rawValue == 20000) } @Test func eventKind_presenceIsEphemeral() { // Both 20000 and 20001 are in the ephemeral range (20000-29999) let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue #expect(presenceKind >= 20000 && presenceKind < 30000) #expect(chatKind >= 20000 && chatKind < 30000) } } // MARK: - Participant Tracker Presence Integration Tests @MainActor struct ParticipantTrackerPresenceTests { @Test func recordParticipant_fromPresenceEvent_countsParticipant() async { let tracker = GeohashParticipantTracker() let context = PresenceTestParticipantContext() tracker.configure(context: context) let geohash = "87yw7" tracker.setActiveGeohash(geohash) // Simulate recording from a presence event tracker.recordParticipant(pubkeyHex: "presence_user_1") #expect(tracker.participantCount(for: geohash) == 1) } @Test func recordParticipant_multiplePresenceEvents_countsUnique() async { let tracker = GeohashParticipantTracker() let context = PresenceTestParticipantContext() tracker.configure(context: context) let geohash = "87yw7" tracker.setActiveGeohash(geohash) // Multiple presence events from same user = 1 participant tracker.recordParticipant(pubkeyHex: "user_a") tracker.recordParticipant(pubkeyHex: "user_a") tracker.recordParticipant(pubkeyHex: "user_a") #expect(tracker.participantCount(for: geohash) == 1) // Different user = 2 participants tracker.recordParticipant(pubkeyHex: "user_b") #expect(tracker.participantCount(for: geohash) == 2) } @Test func recordParticipant_nonActiveGeohash_stillCounts() async { let tracker = GeohashParticipantTracker() let context = PresenceTestParticipantContext() tracker.configure(context: context) // Active geohash is different from where we're recording tracker.setActiveGeohash("active_gh") // Record to a non-active geohash (like when sampling nearby channels) tracker.recordParticipant(pubkeyHex: "nearby_user", geohash: "other_gh") #expect(tracker.participantCount(for: "other_gh") == 1) #expect(tracker.participantCount(for: "active_gh") == 0) } @Test func objectWillChange_firesOnNonActiveGeohashUpdate() async { let tracker = GeohashParticipantTracker() let context = PresenceTestParticipantContext() tracker.configure(context: context) tracker.setActiveGeohash("active_gh") var changeCount = 0 let cancellable = tracker.objectWillChange.sink { _ in changeCount += 1 } // Record to non-active geohash tracker.recordParticipant(pubkeyHex: "user1", geohash: "other_gh") // Should fire objectWillChange even for non-active geohash #expect(changeCount >= 1) _ = cancellable // Keep alive } } // MARK: - Mock for Participant Context (Presence Tests) @MainActor private final class PresenceTestParticipantContext: GeohashParticipantContext { var blockedPubkeys: Set = [] var nicknameMap: [String: String] = [:] var selfPubkey: String? func displayNameForPubkey(_ pubkeyHex: String) -> String { let suffix = String(pubkeyHex.suffix(4)) if let s = selfPubkey, pubkeyHex.lowercased() == s.lowercased() { return "me#\(suffix)" } if let nick = nicknameMap[pubkeyHex.lowercased()] { return "\(nick)#\(suffix)" } return "anon#\(suffix)" } func isBlocked(_ pubkeyHexLowercased: String) -> Bool { blockedPubkeys.contains(pubkeyHexLowercased.lowercased()) } } ================================================ FILE: bitchatTests/GossipSyncManagerTests.swift ================================================ import Foundation import Testing @testable import bitchat struct GossipSyncManagerTests { private let myPeerID = PeerID(str: "0102030405060708") @Test func concurrentPacketIntakeAndSyncRequest() async throws { let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager) let delegate = RecordingDelegate() manager.delegate = delegate try await confirmation("sync request sent") { sent in delegate.onSend = { delegate.onSend = nil sent() } let iterations = 200 let senderID = try #require(Data(hexString: "1122334455667788")) for i in 0.. Void)? private(set) var lastPacket: BitchatPacket? private(set) var packets: [BitchatPacket] = [] private let lock = NSLock() func sendPacket(_ packet: BitchatPacket) { lock.lock() lastPacket = packet packets.append(packet) lock.unlock() onSend?() } func sendPacket(to peerID: PeerID, packet: BitchatPacket) { sendPacket(packet) } func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket { packet } func getConnectedPeers() -> [PeerID] { return [] } } ================================================ FILE: bitchatTests/Info.plist ================================================ CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString 1.0 CFBundleVersion 1 ================================================ FILE: bitchatTests/InputValidatorTests.swift ================================================ // // InputValidatorTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Testing import Foundation @testable import bitchat struct InputValidatorTests { // MARK: - Basic Validation Tests @Test func validStringPassesValidation() throws { let result = InputValidator.validateUserString("Hello World", maxLength: 100) #expect(result == "Hello World") } @Test func emptyStringReturnsNil() throws { let result = InputValidator.validateUserString("", maxLength: 100) #expect(result == nil) } @Test func whitespaceOnlyStringReturnsNil() throws { let result = InputValidator.validateUserString(" \n\t ", maxLength: 100) #expect(result == nil) } @Test func stringExceedingMaxLengthReturnsNil() throws { let longString = String(repeating: "a", count: 101) let result = InputValidator.validateUserString(longString, maxLength: 100) #expect(result == nil) } @Test func stringAtMaxLengthIsAccepted() throws { let exactString = String(repeating: "a", count: 100) let result = InputValidator.validateUserString(exactString, maxLength: 100) #expect(result == exactString) } @Test func whitespaceIsTrimmed() throws { let result = InputValidator.validateUserString(" Hello ", maxLength: 100) #expect(result == "Hello") } // MARK: - Control Character Tests @Test func nullCharacterIsRejected() throws { let stringWithNull = "Hello\u{0000}World" let result = InputValidator.validateUserString(stringWithNull, maxLength: 100) #expect(result == nil) } @Test func bellCharacterIsRejected() throws { let stringWithBell = "Hello\u{0007}World" let result = InputValidator.validateUserString(stringWithBell, maxLength: 100) #expect(result == nil) } @Test func backspaceCharacterIsRejected() throws { let stringWithBackspace = "Hello\u{0008}World" let result = InputValidator.validateUserString(stringWithBackspace, maxLength: 100) #expect(result == nil) } @Test func escapeCharacterIsRejected() throws { let stringWithEscape = "Hello\u{001B}World" let result = InputValidator.validateUserString(stringWithEscape, maxLength: 100) #expect(result == nil) } @Test func deleteCharacterIsRejected() throws { let stringWithDelete = "Hello\u{007F}World" let result = InputValidator.validateUserString(stringWithDelete, maxLength: 100) #expect(result == nil) } @Test func multipleControlCharactersAreRejected() throws { let stringWithMultiple = "Hello\u{0000}\u{0007}\u{001B}World" let result = InputValidator.validateUserString(stringWithMultiple, maxLength: 100) #expect(result == nil) } // MARK: - Unicode and Special Character Tests @Test func emojiIsAccepted() throws { let result = InputValidator.validateUserString("Hello 👋 World", maxLength: 100) #expect(result == "Hello 👋 World") } @Test func unicodeCharactersAreAccepted() throws { let result = InputValidator.validateUserString("Hello 世界 مرحبا", maxLength: 100) #expect(result == "Hello 世界 مرحبا") } @Test func specialCharactersAreAccepted() throws { let result = InputValidator.validateUserString("Hello!@#$%^&*()_+-=[]{}|;':\",./<>?", maxLength: 100) #expect(result == "Hello!@#$%^&*()_+-=[]{}|;':\",./<>?") } // MARK: - Nickname Validation Tests @Test func validNicknameIsAccepted() throws { let result = InputValidator.validateNickname("Alice") #expect(result == "Alice") } @Test func nicknameWithEmojiIsAccepted() throws { let result = InputValidator.validateNickname("Alice 🚀") #expect(result == "Alice 🚀") } @Test func nicknameTooLongIsRejected() throws { let longNickname = String(repeating: "a", count: 51) let result = InputValidator.validateNickname(longNickname) #expect(result == nil) } @Test func nicknameAtMaxLengthIsAccepted() throws { let exactNickname = String(repeating: "a", count: 50) let result = InputValidator.validateNickname(exactNickname) #expect(result == exactNickname) } @Test func nicknameWithControlCharacterIsRejected() throws { let result = InputValidator.validateNickname("Alice\u{0000}") #expect(result == nil) } // MARK: - Timestamp Validation Tests // BCH-01-011: Window reduced from ±1 hour to ±5 minutes @Test func currentTimestampIsValid() throws { let now = Date() let result = InputValidator.validateTimestamp(now) #expect(result == true) } @Test func timestampWithinFiveMinutesIsValid() throws { // 2 minutes ago should be valid (within 5-minute window) let twoMinutesAgo = Date().addingTimeInterval(-2 * 60) let result = InputValidator.validateTimestamp(twoMinutesAgo) #expect(result == true) } @Test func timestampThirtyMinutesAgoIsInvalid() throws { // BCH-01-011: 30 minutes is now outside the 5-minute window let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60) let result = InputValidator.validateTimestamp(thirtyMinutesAgo) #expect(result == false) } @Test func timestampTenMinutesAgoIsInvalid() throws { // 10 minutes is outside the 5-minute window let tenMinutesAgo = Date().addingTimeInterval(-10 * 60) let result = InputValidator.validateTimestamp(tenMinutesAgo) #expect(result == false) } @Test func timestampTenMinutesInFutureIsInvalid() throws { // 10 minutes in future is outside the 5-minute window let tenMinutesFromNow = Date().addingTimeInterval(10 * 60) let result = InputValidator.validateTimestamp(tenMinutesFromNow) #expect(result == false) } @Test func timestampAtFiveMinuteBoundaryIsValid() throws { // Just slightly within the five-minute window (299 seconds) let almostFiveMinutesAgo = Date().addingTimeInterval(-299) let result = InputValidator.validateTimestamp(almostFiveMinutesAgo) #expect(result == true) } @Test func timestampJustOutsideFiveMinuteWindowIsInvalid() throws { // Just outside the five-minute window (301 seconds) let justOverFiveMinutesAgo = Date().addingTimeInterval(-301) let result = InputValidator.validateTimestamp(justOverFiveMinutesAgo) #expect(result == false) } // MARK: - Edge Cases @Test func singleCharacterStringIsAccepted() throws { let result = InputValidator.validateUserString("a", maxLength: 100) #expect(result == "a") } @Test func stringWithOnlyNewlinesIsRejected() throws { let result = InputValidator.validateUserString("\n\n\n", maxLength: 100) #expect(result == nil) } @Test func stringWithMixedWhitespaceIsTrimmed() throws { let result = InputValidator.validateUserString(" \t\nHello\n\t ", maxLength: 100) #expect(result == "Hello") } @Test func stringWithLeadingControlCharacterIsRejected() throws { let result = InputValidator.validateUserString("\u{0000}Hello", maxLength: 100) #expect(result == nil) } @Test func stringWithTrailingControlCharacterIsRejected() throws { let result = InputValidator.validateUserString("Hello\u{0000}", maxLength: 100) #expect(result == nil) } } ================================================ FILE: bitchatTests/Integration/IntegrationTests.swift ================================================ // // IntegrationTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation import CryptoKit import Testing @testable import bitchat struct IntegrationTests { private var helper = TestNetworkHelper() init() { helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString)) helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString)) helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString)) helper.createNode("David", peerID: PeerID(str: UUID().uuidString)) } // MARK: - Multi-Peer Scenarios @Test func fullMeshCommunication() async throws { helper.connectFullMesh() var messageMatrix: [String: Set] = [:] for (senderName, _) in helper.nodes { messageMatrix[senderName] = [] } for (receiverName, receiver) in helper.nodes { receiver.messageDeliveryHandler = { message in let parts = message.content.components(separatedBy: " ") if let last = parts.last, message.content.contains("Hello from") { if receiverName != last { messageMatrix[last]?.insert(receiverName) } } } } for (name, node) in helper.nodes { node.sendMessage("Hello from \(name)") } // Each sender should have reached all other nodes for (sender, receivers) in messageMatrix { let expectedReceivers = Set(helper.nodes.keys.filter { $0 != sender }) #expect(receivers == expectedReceivers, "\(sender) didn't reach all nodes") } } @Test func dynamicTopologyChanges() async throws { // Start with Alice -> Bob -> Charlie helper.connect("Alice", "Bob") helper.connect("Bob", "Charlie") try await confirmation("Topology changes handled") { receiveMessage in var phase = 1 helper.nodes["Charlie"]!.messageDeliveryHandler = { message in if phase == 1 && message.sender == "Alice" { // Now change topology: disconnect Bob, connect Alice-Charlie helper.disconnect("Alice", "Bob") helper.disconnect("Bob", "Charlie") helper.connect("Alice", "Charlie") phase = 2 // Send another message helper.nodes["Alice"]!.sendMessage("Direct message") } else if phase == 2 && message.content == "Direct message" { receiveMessage() } } // Allow relay handler to be set before first send try await sleep(0.05) helper.nodes["Alice"]!.sendMessage("Relayed message") } } @Test func networkPartitionRecovery() async throws { // Create two partitions helper.connect("Alice", "Bob") helper.connect("Charlie", "David") let messagesBeforeMerge = 0 var messagesAfterMerge = 0 try await confirmation("Partitions merge and communicate") { receiveMessage in // Monitor cross-partition messages helper.nodes["David"]!.messageDeliveryHandler = { message in if message.sender == "Alice" { messagesAfterMerge += 1 if messagesAfterMerge == 1 { receiveMessage() } } } // Try to send across partition (should fail) helper.nodes["Alice"]!.sendMessage("Before merge") // Merge partitions after delay try await sleep(0.05) // Connect partitions helper.connect("Bob", "Charlie") // Enable relay helper.setupRelay("Bob", nextHops: ["Charlie"]) helper.setupRelay("Charlie", nextHops: ["David"]) // Send message across merged network helper.nodes["Alice"]!.sendMessage("After merge") } #expect(messagesBeforeMerge == 0) #expect(messagesAfterMerge == 1) } // MARK: - Mixed Message Type Scenarios @Test func mixedPublicPrivateMessages() async throws { helper.connectFullMesh() var publicCount = 0 var privateCount = 0 await confirmation("Mixed messages handled correctly") { completion in // Bob monitors messages helper.nodes["Bob"]!.messageDeliveryHandler = { message in if message.isPrivate && message.recipientNickname == "Bob" { privateCount += 1 } else if !message.isPrivate { publicCount += 1 } if publicCount == 2 && privateCount == 1 { completion() } } // Alice sends mixed messages helper.nodes["Alice"]!.sendMessage("Public 1") helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob") helper.nodes["Alice"]!.sendMessage("Public 2") } #expect(publicCount == 2) #expect(privateCount == 1) } @Test func encryptedAndUnencryptedMix() async throws { helper.connect("Alice", "Bob") // Setup Noise session try helper.establishNoiseSession("Alice", "Bob") var plainCount = 0 var encryptedCount = 0 try await confirmation("Both encrypted and plain messages work") { completion in // Plain path: send public message and count at Bob helper.nodes["Bob"]!.messageDeliveryHandler = { message in if message.content == "Plain message" { plainCount += 1 } if plainCount == 1 && encryptedCount == 1 { completion() } } // Encrypted path: use NoiseSessionManager explicitly let plaintext = "Encrypted message".data(using: .utf8)! let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID) helper.nodes["Bob"]!.packetDeliveryHandler = { packet in if packet.type == MessageType.noiseEncrypted.rawValue { if let data = try? helper.noiseManagers["Bob"]!.decrypt(ciphertext, from: helper.nodes["Alice"]!.peerID), data == plaintext { encryptedCount = 1 if plainCount == 1 { completion() } } } } helper.nodes["Alice"]!.sendMessage("Plain message") // Deliver encrypted packet directly let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext) helper.nodes["Bob"]!.simulateIncomingPacket(encPacket) } } // MARK: - Network Resilience Tests @Test func messageDeliveryUnderChurn() async throws { // Start with stable network helper.connectFullMesh() let totalMessages = 10 try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in // David tracks received messages helper.nodes["David"]!.messageDeliveryHandler = { message in completion() } // Send messages while churning network for i in 0.. 0) #expect(metrics["private", default: 0] > 0) #expect(metrics["mentions", default: 0] > 0) } // MARK: - Security Integration Tests // Replacement for the legacy NACK test: verifies that after a // decryption failure, peers can rehandshake via NoiseSessionManager // and resume secure communication. @Test func rehandshakeAfterDecryptionFailure() throws { // Alice <-> Bob connected helper.connect("Alice", "Bob") // Establish initial Noise session try helper.establishNoiseSession("Alice", "Bob") guard let aliceManager = helper.noiseManagers["Alice"], let bobManager = helper.noiseManagers["Bob"], let alicePeerID = helper.nodes["Alice"]?.peerID, let bobPeerID = helper.nodes["Bob"]?.peerID else { Issue.record("Missing managers or peer IDs") return } // Baseline: encrypt from Alice, decrypt at Bob let plaintext1 = Data("hello-secure".utf8) let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID) let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID) #expect(decrypted1 == plaintext1) // Simulate decryption failure by corrupting ciphertext let corrupted = encrypted1.prefix(15) #expect(throws: NoiseError.invalidCiphertext) { _ = try bobManager.decrypt(corrupted, from: alicePeerID) } // Bob initiates a new handshake; clear Bob's session first so initiateHandshake won't throw bobManager.removeSession(for: alicePeerID) try helper.establishNoiseSession("Bob", "Alice") // After rehandshake, encryption/decryption works again let plaintext2 = Data("hello-again".utf8) let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID) let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID) #expect(decrypted2 == plaintext2) } @Test func endToEndSecurityScenario() async throws { helper.connect("Alice", "Bob") helper.connect("Bob", "Charlie") // Charlie will try to eavesdrop // Establish secure session between Alice and Bob only try helper.establishNoiseSession("Alice", "Bob") await confirmation("Secure communication maintained", expectedCount: 2) { receivedPacket in // Setup encryption at Alice helper.nodes["Alice"]!.packetDeliveryHandler = { packet in if packet.type == 0x01, let message = BitchatMessage(packet.payload), message.isPrivate && packet.recipientID != nil { // Encrypt private messages if let encrypted = try? helper.noiseManagers["Alice"]!.encrypt(packet.payload, for: helper.nodes["Bob"]!.peerID) { let encPacket = BitchatPacket( type: 0x02, senderID: packet.senderID, recipientID: packet.recipientID, timestamp: packet.timestamp, payload: encrypted, signature: packet.signature, ttl: packet.ttl ) helper.nodes["Bob"]!.simulateIncomingPacket(encPacket) } } } // Bob can decrypt helper.nodes["Bob"]!.packetDeliveryHandler = { packet in if packet.type == 0x02 { receivedPacket() if let decrypted = try? helper.noiseManagers["Bob"]!.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID) { #expect(BitchatMessage(decrypted)?.content == "Secret message") } else { Issue.record("Bob was unable to decrypt the message") } // Relay encrypted packet to Charlie helper.nodes["Charlie"]!.simulateIncomingPacket(packet) } } // Charlie cannot decrypt helper.nodes["Charlie"]!.packetDeliveryHandler = { packet in if packet.type == 0x02 { receivedPacket() #expect(throws: NoiseSessionError.sessionNotFound, "Charlie should not be able to decrypt") { _ = try helper.noiseManagers["Charlie"]?.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID) } } } // Send encrypted private message helper.nodes["Alice"]!.sendPrivateMessage("Secret message", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob") } } } ================================================ FILE: bitchatTests/Integration/TestNetworkHelper.swift ================================================ // // TestNetworkHelper.swift // bitchatTests // // Extracted shared, mutable integration state for nodes and noise sessions. // Keeps test containers nonmutating (Swift Testing-friendly). // import Foundation import CryptoKit @testable import bitchat final class TestNetworkHelper { // Public, read-only views for tests; mutation only through methods var nodes: [String: MockBLEService] = [:] var noiseManagers: [String: NoiseSessionManager] = [:] let mockKeychain = MockKeychain() private let bus = MockBLEBus(autoFloodEnabled: true) // MARK: - Node/Manager management @discardableResult func createNode(_ name: String, peerID: PeerID) -> MockBLEService { let node = MockBLEService(bus: bus) node.myPeerID = peerID node.mockNickname = name nodes[name] = node // Create/replace Noise manager for this node let key = Curve25519.KeyAgreement.PrivateKey() noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain) return node } func getNode(_ name: String) -> MockBLEService? { nodes[name] } func getManager(_ name: String) -> NoiseSessionManager? { noiseManagers[name] } // MARK: - Topology func connect(_ a: String, _ b: String) { guard let n1 = nodes[a], let n2 = nodes[b] else { return } n1.simulateConnectedPeer(n2.peerID) n2.simulateConnectedPeer(n1.peerID) } func disconnect(_ a: String, _ b: String) { guard let n1 = nodes[a], let n2 = nodes[b] else { return } n1.simulateDisconnectedPeer(n2.peerID) n2.simulateDisconnectedPeer(n1.peerID) } func connectFullMesh() { let names = Array(nodes.keys) for i in 0.. 1 else { return } if let message = BitchatMessage(packet.payload) { guard message.senderPeerID != node.peerID else { return } let relayMessage = BitchatMessage( id: message.id, sender: message.sender, content: message.content, timestamp: message.timestamp, isRelay: true, originalSender: message.isRelay ? message.originalSender : message.sender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, senderPeerID: message.senderPeerID, mentions: message.mentions ) if let relayPayload = relayMessage.toBinaryPayload() { let relayPacket = BitchatPacket( type: packet.type, senderID: packet.senderID, recipientID: packet.recipientID, timestamp: packet.timestamp, payload: relayPayload, signature: packet.signature, ttl: packet.ttl - 1 ) for hop in nextHops { self.nodes[hop]?.simulateIncomingPacket(relayPacket) } } } } } // MARK: - Noise sessions func establishNoiseSession(_ node1: String, _ node2: String) throws { guard let manager1 = noiseManagers[node1], let manager2 = noiseManagers[node2], let peer1ID = nodes[node1]?.peerID, let peer2ID = nodes[node2]?.peerID else { return } let msg1 = try manager1.initiateHandshake(with: peer2ID) let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)! let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)! _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3) } } ================================================ FILE: bitchatTests/KeychainErrorHandlingTests.swift ================================================ // // KeychainErrorHandlingTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // // BCH-01-009: Tests for proper keychain error classification and handling import Testing import Foundation @testable import bitchat struct KeychainErrorHandlingTests { // MARK: - Error Classification Tests @Test func keychainReadResult_successIsNotRecoverable() throws { let result = KeychainReadResult.success(Data([1, 2, 3])) #expect(result.isRecoverableError == false) } @Test func keychainReadResult_itemNotFoundIsNotRecoverable() throws { let result = KeychainReadResult.itemNotFound #expect(result.isRecoverableError == false) } @Test func keychainReadResult_deviceLockedIsRecoverable() throws { let result = KeychainReadResult.deviceLocked #expect(result.isRecoverableError == true) } @Test func keychainReadResult_authenticationFailedIsRecoverable() throws { let result = KeychainReadResult.authenticationFailed #expect(result.isRecoverableError == true) } @Test func keychainReadResult_accessDeniedIsNotRecoverable() throws { let result = KeychainReadResult.accessDenied #expect(result.isRecoverableError == false) } @Test func keychainSaveResult_successIsNotRecoverable() throws { let result = KeychainSaveResult.success #expect(result.isRecoverableError == false) } @Test func keychainSaveResult_duplicateItemIsRecoverable() throws { let result = KeychainSaveResult.duplicateItem #expect(result.isRecoverableError == true) } @Test func keychainSaveResult_deviceLockedIsRecoverable() throws { let result = KeychainSaveResult.deviceLocked #expect(result.isRecoverableError == true) } @Test func keychainSaveResult_storageFullIsNotRecoverable() throws { let result = KeychainSaveResult.storageFull #expect(result.isRecoverableError == false) } // MARK: - Mock Keychain Error Simulation Tests @Test func mockKeychain_canSimulateReadErrors() throws { let keychain = MockKeychain() // Simulate access denied error keychain.simulatedReadError = .accessDenied let result = keychain.getIdentityKeyWithResult(forKey: "testKey") switch result { case .accessDenied: // Expected break default: throw KeychainTestError("Expected accessDenied, got \(result)") } } @Test func mockKeychain_canSimulateSaveErrors() throws { let keychain = MockKeychain() // Simulate storage full error keychain.simulatedSaveError = .storageFull let result = keychain.saveIdentityKeyWithResult(Data([1, 2, 3]), forKey: "testKey") switch result { case .storageFull: // Expected break default: throw KeychainTestError("Expected storageFull, got \(result)") } } @Test func mockKeychain_returnsItemNotFoundForMissingKey() throws { let keychain = MockKeychain() let result = keychain.getIdentityKeyWithResult(forKey: "nonExistentKey") switch result { case .itemNotFound: // Expected break default: throw KeychainTestError("Expected itemNotFound, got \(result)") } } @Test func mockKeychain_returnsSuccessForExistingKey() throws { let keychain = MockKeychain() let testData = Data([1, 2, 3, 4, 5]) // First save the key _ = keychain.saveIdentityKey(testData, forKey: "existingKey") // Now read it back let result = keychain.getIdentityKeyWithResult(forKey: "existingKey") switch result { case .success(let data): #expect(data == testData) default: throw KeychainTestError("Expected success, got \(result)") } } @Test func mockKeychain_saveWithResultStoresData() throws { let keychain = MockKeychain() let testData = Data([10, 20, 30]) let saveResult = keychain.saveIdentityKeyWithResult(testData, forKey: "newKey") switch saveResult { case .success: // Verify data was stored let readResult = keychain.getIdentityKeyWithResult(forKey: "newKey") switch readResult { case .success(let data): #expect(data == testData) default: throw KeychainTestError("Expected to read back saved data") } default: throw KeychainTestError("Expected save success, got \(saveResult)") } } // MARK: - NoiseEncryptionService Integration Tests @Test func noiseEncryptionService_generatesNewIdentityWhenMissing() throws { let keychain = MockKeychain() // Create service with empty keychain - should generate new identity let service = NoiseEncryptionService(keychain: keychain) // Should have generated and saved keys #expect(service.getStaticPublicKeyData().count == 32) #expect(service.getSigningPublicKeyData().count == 32) // Keys should be persisted let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") switch noiseKeyResult { case .success: // Expected - key was saved break default: throw KeychainTestError("Expected noise key to be saved") } } @Test func noiseEncryptionService_loadsExistingIdentity() throws { let keychain = MockKeychain() // Create first service to generate identity let service1 = NoiseEncryptionService(keychain: keychain) let originalPublicKey = service1.getStaticPublicKeyData() let originalSigningKey = service1.getSigningPublicKeyData() // Create second service - should load same identity let service2 = NoiseEncryptionService(keychain: keychain) #expect(service2.getStaticPublicKeyData() == originalPublicKey) #expect(service2.getSigningPublicKeyData() == originalSigningKey) } @Test func noiseEncryptionService_handlesAccessDeniedGracefully() throws { let keychain = MockKeychain() keychain.simulatedReadError = .accessDenied // Service should still initialize with ephemeral key let service = NoiseEncryptionService(keychain: keychain) // Should have an identity (ephemeral) #expect(service.getStaticPublicKeyData().count == 32) #expect(service.getSigningPublicKeyData().count == 32) } @Test func noiseEncryptionService_handlesDeviceLockedGracefully() throws { let keychain = MockKeychain() keychain.simulatedReadError = .deviceLocked // Service should still initialize with ephemeral key let service = NoiseEncryptionService(keychain: keychain) // Should have an identity (ephemeral) #expect(service.getStaticPublicKeyData().count == 32) } } // Helper error type for tests private struct KeychainTestError: Error, CustomStringConvertible { let message: String init(_ message: String) { self.message = message } var description: String { message } } ================================================ FILE: bitchatTests/Localization/PrimaryLocalizationKeys.json ================================================ { "app": [ "app_info.app_name", "app_info.close", "app_info.done", "app_info.features.encryption.title", "app_info.features.offline.title", "app_info.warning.message", "common.cancel", "common.close", "common.copy", "common.ok", "content.accessibility.people_count", "content.accessibility.send_message", "content.actions.title", "content.alert.bluetooth_required.permission", "content.alert.bluetooth_required.settings", "content.alert.bluetooth_required.title", "content.delivery.delivered_to", "content.input.message_placeholder", "fingerprint.action.mark_verified", "fingerprint.badge.not_verified", "fingerprint.badge.verified", "fingerprint.title", "location_channels.action.open_settings", "location_channels.action.request_permissions", "location_channels.title", "location_notes.header", "system.tor.started" ], "shareExtension": [ "share.fallback.shared_link_title", "share.status.failed_to_encode", "share.status.no_shareable_content", "share.status.nothing_to_share", "share.status.shared_link", "share.status.shared_text" ], "expectedValues": { "ar": { "common.ok": "موافق", "content.input.message_placeholder": "اكتب رسالة...", "location_channels.title": "#قنوات الموقع", "system.tor.started": "tor يعمل. كل الدردشة تمر عبر tor للخصوصية.", "share.status.shared_text": "✓ تم إرسال النص إلى bitchat", "share.status.shared_link": "✓ تم إرسال الرابط إلى bitchat", "share.status.failed_to_encode": "تعذر ترميز الرابط", "common.cancel": "إلغاء", "common.close": "إغلاق", "common.copy": "نسخ", "app_info.close": "إغلاق", "app_info.done": "تم", "content.alert.bluetooth_required.permission": "تحتاج bitchat إلى إذن bluetooth للاتصال بالأجهزة القريبة. فعّل الوصول في الإعدادات.", "content.alert.bluetooth_required.settings": "الإعدادات", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "تشفير طرف لطرف", "app_info.features.offline.title": "تواصل بدون اتصال", "fingerprint.badge.verified": "✓ مُتحقق", "fingerprint.badge.not_verified": "⚠️ غير مُتحقق", "fingerprint.action.mark_verified": "وضع علامة تم التحقق", "location_channels.action.open_settings": "فتح الإعدادات", "content.actions.title": "إجراءات", "content.accessibility.send_message": "إرسال رسالة", "share.status.nothing_to_share": "لا شيء لمشاركته", "share.status.no_shareable_content": "لا محتوى قابلاً للمشاركة", "share.fallback.shared_link_title": "رابط مشترك" }, "de": { "common.ok": "OK", "content.input.message_placeholder": "nachricht eingeben...", "location_channels.title": "#standort-kanäle", "system.tor.started": "tor läuft. der gesamte chat wird über tor geleitet.", "share.status.shared_text": "✓ text zu bitchat geteilt", "share.status.shared_link": "✓ link zu bitchat geteilt", "share.status.failed_to_encode": "link konnte nicht codiert werden", "common.cancel": "abbrechen", "common.close": "schließen", "common.copy": "kopieren", "app_info.close": "schließen", "app_info.done": "FERTIG", "content.alert.bluetooth_required.permission": "bitchat benötigt bluetooth-berechtigung, um sich mit geräten in der nähe zu verbinden. erlaube den zugriff in den einstellungen.", "content.alert.bluetooth_required.settings": "einstellungen", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "end-to-end-verschlüsselung", "app_info.features.offline.title": "offline-kommunikation", "fingerprint.badge.verified": "✓ VERIFIZIERT", "fingerprint.badge.not_verified": "⚠️ NICHT VERIFIZIERT", "fingerprint.action.mark_verified": "als verifiziert markieren", "location_channels.action.open_settings": "einstellungen öffnen", "content.actions.title": "aktionen", "content.accessibility.send_message": "nachricht senden", "share.status.nothing_to_share": "nichts zum teilen", "share.status.no_shareable_content": "kein teilbarer inhalt", "share.fallback.shared_link_title": "geteilter link" }, "es": { "common.ok": "aceptar", "content.input.message_placeholder": "escribe un mensaje...", "location_channels.title": "#canales de ubicación", "system.tor.started": "tor se inició. Todo el chat se enruta por Tor para privacidad.", "share.status.shared_text": "✓ texto compartido con bitchat", "share.status.shared_link": "✓ enlace compartido con bitchat", "share.status.failed_to_encode": "no se pudo codificar el enlace", "common.cancel": "cancelar", "common.close": "cerrar", "common.copy": "copiar", "app_info.close": "cerrar", "app_info.done": "LISTO", "content.alert.bluetooth_required.permission": "bitChat necesita permiso de Bluetooth para conectarse con dispositivos cercanos. Habilita el acceso en Ajustes.", "content.alert.bluetooth_required.settings": "ajustes", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "cifrado de extremo a extremo", "app_info.features.offline.title": "comunicación sin conexión", "fingerprint.badge.verified": "✓ VERIFICADO", "fingerprint.badge.not_verified": "⚠️ NO VERIFICADO", "fingerprint.action.mark_verified": "marcar como verificado", "location_channels.action.open_settings": "abrir ajustes", "content.actions.title": "acciones", "content.accessibility.send_message": "enviar mensaje", "share.status.nothing_to_share": "nada que compartir", "share.status.no_shareable_content": "sin contenido que se pueda compartir", "share.fallback.shared_link_title": "enlace compartido" }, "fr": { "common.ok": "OK", "content.input.message_placeholder": "écris un message...", "location_channels.title": "#canaux localisation", "system.tor.started": "tor a démarré. tout le chat passe par tor pour la confidentialité.", "share.status.shared_text": "✓ texte partagé vers bitchat", "share.status.shared_link": "✓ lien partagé vers bitchat", "share.status.failed_to_encode": "échec de l'encodage du lien", "common.cancel": "annuler", "common.close": "fermer", "common.copy": "copier", "app_info.close": "fermer", "app_info.done": "TERMINÉ", "content.alert.bluetooth_required.permission": "bitchat a besoin de l'autorisation bluetooth pour se connecter aux appareils proches. active l'accès dans réglages.", "content.alert.bluetooth_required.settings": "réglages", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "chiffrement de bout en bout", "app_info.features.offline.title": "communication hors ligne", "fingerprint.badge.verified": "✓ VÉRIFIÉ", "fingerprint.badge.not_verified": "⚠️ NON VÉRIFIÉ", "fingerprint.action.mark_verified": "marquer comme vérifié", "location_channels.action.open_settings": "ouvrir réglages", "content.actions.title": "actions", "content.accessibility.send_message": "envoyer le message", "share.status.nothing_to_share": "rien à partager", "share.status.no_shareable_content": "aucun contenu partageable", "share.fallback.shared_link_title": "lien partagé" }, "he": { "common.ok": "OK", "content.input.message_placeholder": "כתוב הודעה...", "location_channels.title": "#ערוצי מיקום", "system.tor.started": "tor פעיל. כל הצ'אט עובר דרך tor לפרטיות.", "share.status.shared_text": "✓ הטקסט נשלח אל bitchat", "share.status.shared_link": "✓ הקישור נשלח אל bitchat", "share.status.failed_to_encode": "לא ניתן לקודד את הקישור", "common.cancel": "ביטול", "common.close": "סגור", "common.copy": "העתק", "app_info.close": "סגור", "app_info.done": "בוצע", "content.alert.bluetooth_required.permission": "bitchat צריכה הרשאת bluetooth כדי להתחבר למכשירים קרובים. אפשר גישה בהגדרות.", "content.alert.bluetooth_required.settings": "הגדרות", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "הצפנה מקצה לקצה", "app_info.features.offline.title": "תקשורת לא מקוונת", "fingerprint.badge.verified": "✓ מאומת", "fingerprint.badge.not_verified": "⚠️ לא מאומת", "fingerprint.action.mark_verified": "סמן כמאומת", "location_channels.action.open_settings": "פתח הגדרות", "content.actions.title": "פעולות", "content.accessibility.send_message": "שלח הודעה", "share.status.nothing_to_share": "אין מה לשתף", "share.status.no_shareable_content": "אין תוכן שניתן לשתף", "share.fallback.shared_link_title": "קישור משותף" }, "id": { "common.ok": "OK", "content.input.message_placeholder": "ketik pesan...", "location_channels.title": "#kanal lokasi", "system.tor.started": "tor berjalan. seluruh chat dirutekan lewat tor demi privasi.", "share.status.shared_text": "✓ teks dikirim ke bitchat", "share.status.shared_link": "✓ tautan dikirim ke bitchat", "share.status.failed_to_encode": "gagal mengodekan tautan", "common.cancel": "batal", "common.close": "tutup", "common.copy": "salin", "app_info.close": "tutup", "app_info.done": "SELESAI", "content.alert.bluetooth_required.permission": "bitchat memerlukan izin bluetooth untuk terhubung dengan perangkat dekat. aktifkan akses di pengaturan.", "content.alert.bluetooth_required.settings": "pengaturan", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "enkripsi ujung ke ujung", "app_info.features.offline.title": "komunikasi offline", "fingerprint.badge.verified": "✓ TERVERIFIKASI", "fingerprint.badge.not_verified": "⚠️ BELUM TERVERIFIKASI", "fingerprint.action.mark_verified": "tandai sebagai terverifikasi", "location_channels.action.open_settings": "buka pengaturan", "content.actions.title": "aksi", "content.accessibility.send_message": "kirim pesan", "share.status.nothing_to_share": "tidak ada yang bisa dibagikan", "share.status.no_shareable_content": "tidak ada konten yang bisa dibagikan", "share.fallback.shared_link_title": "tautan dibagikan" }, "it": { "common.ok": "OK", "content.input.message_placeholder": "scrivi un messaggio...", "location_channels.title": "#canali posizione", "system.tor.started": "tor è avviato. tutta la chat passa da tor per la privacy.", "share.status.shared_text": "✓ testo inviato a bitchat", "share.status.shared_link": "✓ link inviato a bitchat", "share.status.failed_to_encode": "impossibile codificare il link", "common.cancel": "annulla", "common.close": "chiudi", "common.copy": "copia", "app_info.close": "chiudi", "app_info.done": "FATTO", "content.alert.bluetooth_required.permission": "bitchat richiede l'autorizzazione bluetooth per collegarsi ai dispositivi vicini. abilita l'accesso nelle impostazioni.", "content.alert.bluetooth_required.settings": "impostazioni", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "crittografia end-to-end", "app_info.features.offline.title": "comunicazione offline", "fingerprint.badge.verified": "✓ VERIFICATO", "fingerprint.badge.not_verified": "⚠️ NON VERIFICATO", "fingerprint.action.mark_verified": "segna come verificato", "location_channels.action.open_settings": "apri impostazioni", "content.actions.title": "azioni", "content.accessibility.send_message": "invia messaggio", "share.status.nothing_to_share": "niente da condividere", "share.status.no_shareable_content": "nessun contenuto condivisibile", "share.fallback.shared_link_title": "link condiviso" }, "ja": { "common.ok": "OK", "content.input.message_placeholder": "メッセージを入力...", "location_channels.title": "#ロケーションチャンネル", "system.tor.started": "torを起動しました。全チャットをtor経由で配信します。", "share.status.shared_text": "✓ bitchatにテキストを共有", "share.status.shared_link": "✓ bitchatにリンクを共有", "share.status.failed_to_encode": "リンクのエンコードに失敗しました", "common.cancel": "キャンセル", "common.close": "閉じる", "common.copy": "コピー", "app_info.close": "閉じる", "app_info.done": "完了", "content.alert.bluetooth_required.permission": "bitchatは近くのデバイスと接続するためbluetooth権限が必要です。設定でアクセスを有効にしてください。", "content.alert.bluetooth_required.settings": "設定", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "エンドツーエンド暗号", "app_info.features.offline.title": "オフライン通信", "fingerprint.badge.verified": "✓ 検証済み", "fingerprint.badge.not_verified": "⚠️ 未検証", "fingerprint.action.mark_verified": "検証済みにする", "location_channels.action.open_settings": "設定を開く", "content.actions.title": "アクション", "content.accessibility.send_message": "メッセージ送信", "share.status.nothing_to_share": "共有できるものがありません", "share.status.no_shareable_content": "共有可能なコンテンツがありません", "share.fallback.shared_link_title": "共有リンク" }, "ko": { "common.ok": "확인", "content.input.message_placeholder": "메시지를 입력하세요...", "location_channels.title": "#위치 채널", "system.tor.started": "tor가 시작되었습니다. IP 보호를 위해 모든 대화를 tor를 통해 라우팅합니다.", "share.status.shared_text": "✓ bitchat으로 텍스트를 공유했습니다", "share.status.shared_link": "✓ bitchat으로 링크를 공유했습니다", "share.status.failed_to_encode": "링크를 인코딩하는 데 실패했습니다", "common.cancel": "취소", "common.close": "닫기", "common.copy": "복사", "app_info.close": "닫기", "app_info.done": "확인", "content.alert.bluetooth_required.permission": "bitchat은 주변 기기와 연결하기 위해 bluetooth 권한이 필요합니다. 설정에서 bluetooth 접근을 활성화해주세요.", "content.alert.bluetooth_required.settings": "설정", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "종단간 암호화", "app_info.features.offline.title": "오프라인 통신", "fingerprint.badge.verified": "✓ 인증됨", "fingerprint.badge.not_verified": "⚠️ 인증되지 않음", "fingerprint.action.mark_verified": "인증됨으로 표시", "location_channels.action.open_settings": "설정 열기", "content.actions.title": "작업", "content.accessibility.send_message": "메시지 보내기", "share.status.nothing_to_share": "공유할 내용이 없습니다", "share.status.no_shareable_content": "공유할 수 있는 내용이 없습니다", "share.fallback.shared_link_title": "공유된 링크" }, "ne": { "common.ok": "ठिक", "content.input.message_placeholder": "सन्देश टाइप गर...", "location_channels.title": "#स्थान च्यानल", "system.tor.started": "tor सुरु भयो। गोपनीयताका लागि पूरा च्याट tor मार्फत जान्छ।", "share.status.shared_text": "✓ bitchat मा पाठ पठाइयो", "share.status.shared_link": "✓ bitchat मा लिङ्क पठाइयो", "share.status.failed_to_encode": "लिङ्क सङ्केत गर्न सकेन", "common.cancel": "रद्द", "common.close": "बन्द", "common.copy": "प्रतिलिपि", "app_info.close": "बन्द", "app_info.done": "सम्पन्न", "content.alert.bluetooth_required.permission": "bitchat लाई नजिकका उपकरणसँग जडान हुन bluetooth अनुमति चाहिन्छ। सेटिङमा पहुँच सक्षम गर।", "content.alert.bluetooth_required.settings": "सेटिङ", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "एन्ड-टु-एन्ड सङ्केत", "app_info.features.offline.title": "अफलाइन सञ्चार", "fingerprint.badge.verified": "✓ प्रमाणित", "fingerprint.badge.not_verified": "⚠️ प्रमाणित छैन", "fingerprint.action.mark_verified": "प्रमाणित चिन्ह लगाउ", "location_channels.action.open_settings": "सेटिङ खोल", "content.actions.title": "कार्य", "content.accessibility.send_message": "सन्देश पठाउ", "share.status.nothing_to_share": "बाँड्ने केही छैन", "share.status.no_shareable_content": "बाँड्न मिल्ने सामग्री छैन", "share.fallback.shared_link_title": "साझा गरिएको लिङ्क" }, "pt-BR": { "common.ok": "OK", "content.input.message_placeholder": "digite uma mensagem...", "location_channels.title": "#canais de localização", "system.tor.started": "tor iniciou. todo o chat é roteado por tor para privacidade.", "share.status.shared_text": "✓ texto enviado para bitchat", "share.status.shared_link": "✓ link enviado para bitchat", "share.status.failed_to_encode": "falha ao codificar link", "common.cancel": "cancelar", "common.close": "fechar", "common.copy": "copiar", "app_info.close": "fechar", "app_info.done": "CONCLUÍDO", "content.alert.bluetooth_required.permission": "bitchat precisa de permissão de bluetooth para conectar com dispositivos próximos. habilite o acesso em ajustes.", "content.alert.bluetooth_required.settings": "ajustes", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "criptografia ponto a ponto", "app_info.features.offline.title": "comunicação offline", "fingerprint.badge.verified": "✓ VERIFICADO", "fingerprint.badge.not_verified": "⚠️ NÃO VERIFICADO", "fingerprint.action.mark_verified": "marcar como verificado", "location_channels.action.open_settings": "abrir ajustes", "content.actions.title": "ações", "content.accessibility.send_message": "enviar mensagem", "share.status.nothing_to_share": "nada para compartilhar", "share.status.no_shareable_content": "nenhum conteúdo compartilhável", "share.fallback.shared_link_title": "link compartilhado" }, "ru": { "common.ok": "OK", "content.input.message_placeholder": "напиши сообщение...", "location_channels.title": "#каналы локации", "system.tor.started": "tor запущен. весь чат идёт через tor для приватности.", "share.status.shared_text": "✓ текст отправлен в bitchat", "share.status.shared_link": "✓ ссылка отправлена в bitchat", "share.status.failed_to_encode": "не удалось закодировать ссылку", "common.cancel": "отмена", "common.close": "закрыть", "common.copy": "копировать", "app_info.close": "закрыть", "app_info.done": "ГОТОВО", "content.alert.bluetooth_required.permission": "bitchat нужен доступ к bluetooth, чтобы соединяться с ближайшими устройствами. включи разрешение в настройках.", "content.alert.bluetooth_required.settings": "настройки", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "сквозное шифрование", "app_info.features.offline.title": "офлайн-связь", "fingerprint.badge.verified": "✓ ПРОВЕРЕНО", "fingerprint.badge.not_verified": "⚠️ НЕ ПРОВЕРЕНО", "fingerprint.action.mark_verified": "пометить как проверено", "location_channels.action.open_settings": "открыть настройки", "content.actions.title": "действия", "content.accessibility.send_message": "отправить сообщение", "share.status.nothing_to_share": "нечем поделиться", "share.status.no_shareable_content": "нет подходящего контента", "share.fallback.shared_link_title": "поделился ссылкой" }, "uk": { "common.ok": "OK", "content.input.message_placeholder": "напиши повідомлення...", "location_channels.title": "#канали локації", "system.tor.started": "tor запущено. увесь чат іде через tor для приватності.", "share.status.shared_text": "✓ текст надіслано в bitchat", "share.status.shared_link": "✓ посилання надіслано в bitchat", "share.status.failed_to_encode": "не вдалося закодувати посилання", "common.cancel": "скасувати", "common.close": "закрити", "common.copy": "скопіювати", "app_info.close": "закрити", "app_info.done": "ГОТОВО", "content.alert.bluetooth_required.permission": "bitchat потребує дозволу bluetooth для з'єднання з пристроями поруч. ввімкни доступ у налаштуваннях.", "content.alert.bluetooth_required.settings": "налаштування", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "скрізьове шифрування", "app_info.features.offline.title": "офлайн-зв'язок", "fingerprint.badge.verified": "✓ ПЕРЕВІРЕНО", "fingerprint.badge.not_verified": "⚠️ НЕ ПЕРЕВІРЕНО", "fingerprint.action.mark_verified": "позначити як перевірено", "location_channels.action.open_settings": "відкрити налаштування", "content.actions.title": "дії", "content.accessibility.send_message": "надіслати повідомлення", "share.status.nothing_to_share": "нема чим ділитися", "share.status.no_shareable_content": "нема відповідного контенту", "share.fallback.shared_link_title": "спільне посилання" }, "zh-Hans": { "common.ok": "确定", "content.input.message_placeholder": "输入消息...", "location_channels.title": "#位置频道", "system.tor.started": "tor 已启动。所有聊天通过 tor 路由以保护 IP。", "share.status.shared_text": "✓ 已将文本分享至 bitchat", "share.status.shared_link": "✓ 已将链接分享至 bitchat", "share.status.failed_to_encode": "无法编码链接", "common.cancel": "取消", "common.close": "关闭", "common.copy": "复制", "app_info.close": "关闭", "app_info.done": "完成", "content.alert.bluetooth_required.permission": "bitchat 需要 bluetooth 权限以连接附近设备。请在设置中启用访问。", "content.alert.bluetooth_required.settings": "设置", "app_info.app_name": "bitchat", "app_info.features.encryption.title": "端到端加密", "app_info.features.offline.title": "离线通信", "fingerprint.badge.verified": "✓ 已验证", "fingerprint.badge.not_verified": "⚠️ 未验证", "fingerprint.action.mark_verified": "标记为已验证", "location_channels.action.open_settings": "打开设置", "content.actions.title": "操作", "content.accessibility.send_message": "发送消息", "share.status.nothing_to_share": "没有可分享的内容", "share.status.no_shareable_content": "没有可分享的素材", "share.fallback.shared_link_title": "分享的链接" } }, "testLocales": [ "en", "ar", "de", "es", "fr", "he", "id", "it", "ja", "ko", "ne", "pt-BR", "ru", "uk", "zh-Hans" ] } ================================================ FILE: bitchatTests/LocationChannelsTests.swift ================================================ import Testing import Foundation @testable import bitchat struct LocationChannelsTests { @Test func geohashEncoderPrecisionMapping() { // Sanity: known coords (Statue of Liberty approx) let lat = 40.6892 let lon = -74.0445 let block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision) let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.precision) let city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision) let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision) let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision) #expect(block.count == 7) #expect(neighborhood.count == 6) #expect(city.count == 5) #expect(region.count == 4) #expect(country.count == 2) // All prefixes must match progressively #expect(block.hasPrefix(neighborhood)) #expect(neighborhood.hasPrefix(city)) #expect(city.hasPrefix(region)) #expect(region.hasPrefix(country)) } @Test func nostrGeohashFilterEncoding() throws { let gh = "u4pruy" let filter = NostrFilter.geohashEphemeral(gh) let data = try JSONEncoder().encode(filter) let json = String(data: data, encoding: .utf8) ?? "" // Expect kinds includes 20000 and tag filter '#g':[gh] #expect(json.contains("20000")) #expect(json.contains("\"#g\":[\"\(gh)\"]")) } @Test func perGeohashIdentityDeterministic() throws { // Derive twice for same geohash; should be identical let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let gh = "u4pruy" let id1 = try idBridge.deriveIdentity(forGeohash: gh) let id2 = try idBridge.deriveIdentity(forGeohash: gh) #expect(id1.publicKeyHex == id2.publicKeyHex) } @Test func geohashNeighborsNearPoleSkipOutOfBoundsCells() { let nearPole = Geohash.encode(latitude: 89.9999, longitude: 0.0, precision: 8) let neighbors = Geohash.neighbors(of: nearPole) #expect(neighbors.isEmpty == false) #expect(neighbors.count < 8) } } ================================================ FILE: bitchatTests/LocationNotesManagerTests.swift ================================================ import Testing import Foundation @testable import bitchat @MainActor struct LocationNotesManagerTests { @Test func subscribeWithoutRelays_setsNoRelaysState() { var subscribeCalled = false let deps = LocationNotesDependencies( relayLookup: { _, _ in [] }, subscribe: { _, _, _, _, _ in subscribeCalled = true }, unsubscribe: { _ in }, sendEvent: { _, _ in }, deriveIdentity: { _ in try NostrIdentity.generate() }, now: { Date() } ) let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) #expect(subscribeCalled == false) #expect(manager.state == .noRelays) #expect(manager.initialLoadComplete) #expect(manager.errorMessage == String(localized: "location_notes.error.no_relays")) } @Test func sendWithoutRelays_surfacesNoRelaysError() { var sendCalled = false let deps = LocationNotesDependencies( relayLookup: { _, _ in [] }, subscribe: { _, _, _, _, _ in }, unsubscribe: { _ in }, sendEvent: { _, _ in sendCalled = true }, deriveIdentity: { _ in throw TestError.shouldNotDerive }, now: { Date() } ) let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) manager.send(content: "hello", nickname: "tester") #expect(sendCalled == false) #expect(manager.state == .noRelays) #expect(manager.errorMessage == String(localized: "location_notes.error.no_relays")) } @Test func subscribeUsesGeoRelaysAndAppendsNotes() throws { var relaysCaptured: [String] = [] var storedHandler: ((NostrEvent) -> Void)? var storedEOSE: (() -> Void)? let deps = LocationNotesDependencies( relayLookup: { _, _ in ["wss://relay.one"] }, subscribe: { filter, id, relays, handler, eose in #expect(filter.kinds == [1]) #expect(!id.isEmpty) relaysCaptured = relays storedHandler = handler storedEOSE = eose }, unsubscribe: { _ in }, sendEvent: { _, _ in }, deriveIdentity: { _ in throw TestError.shouldNotDerive }, now: { Date() } ) let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) #expect(relaysCaptured == ["wss://relay.one"]) #expect(manager.state == .loading) let identity = try NostrIdentity.generate() let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .textNote, tags: [["g", "u4pruydq"]], content: "hi" ) let signed = try event.sign(with: identity.schnorrSigningKey()) storedHandler?(signed) storedEOSE?() #expect(manager.state == .ready) #expect(manager.notes.count == 1) #expect(manager.notes.first?.content == "hi") } @Test func setGeohash_invalidValueIsIgnored() { var subscribeCount = 0 let deps = LocationNotesDependencies( relayLookup: { _, _ in ["wss://relay.one"] }, subscribe: { _, _, _, _, _ in subscribeCount += 1 }, unsubscribe: { _ in }, sendEvent: { _, _ in }, deriveIdentity: { _ in try NostrIdentity.generate() }, now: { Date() } ) let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) manager.setGeohash("not-valid") #expect(manager.geohash == "u4pruydq") #expect(subscribeCount == 1) } @Test func refreshAndCancel_manageSubscriptions() { var subscribeIDs: [String] = [] var unsubscribedIDs: [String] = [] let deps = LocationNotesDependencies( relayLookup: { _, _ in ["wss://relay.one"] }, subscribe: { _, id, _, _, _ in subscribeIDs.append(id) }, unsubscribe: { id in unsubscribedIDs.append(id) }, sendEvent: { _, _ in }, deriveIdentity: { _ in try NostrIdentity.generate() }, now: { Date() } ) let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) manager.refresh() manager.cancel() #expect(subscribeIDs.count == 2) #expect(unsubscribedIDs.count == 2) #expect(manager.state == .idle) #expect(manager.errorMessage == nil) } @Test func send_successCreatesLocalEchoAndClearsError() throws { var sentEvents: [NostrEvent] = [] let identity = try NostrIdentity.generate() let deps = LocationNotesDependencies( relayLookup: { _, _ in ["wss://relay.one"] }, subscribe: { _, _, _, _, _ in }, unsubscribe: { _ in }, sendEvent: { event, _ in sentEvents.append(event) }, deriveIdentity: { _ in identity }, now: { Date(timeIntervalSince1970: 123_456) } ) let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) manager.send(content: " hello note ", nickname: "Builder") #expect(sentEvents.count == 1) #expect(manager.state == .ready) #expect(manager.errorMessage == nil) #expect(manager.notes.first?.content == "hello note") #expect(manager.notes.first?.displayName.hasPrefix("Builder#") == true) } @Test func send_failureFormatsErrorMessageAndClearErrorRemovesIt() { let deps = LocationNotesDependencies( relayLookup: { _, _ in ["wss://relay.one"] }, subscribe: { _, _, _, _, _ in }, unsubscribe: { _ in }, sendEvent: { _, _ in }, deriveIdentity: { _ in throw TestError.shouldNotDerive }, now: { Date() } ) let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) manager.send(content: "hello", nickname: "Builder") #expect(manager.errorMessage?.isEmpty == false) manager.clearError() #expect(manager.errorMessage == nil) } private enum TestError: Error { case shouldNotDerive } } ================================================ FILE: bitchatTests/MessageDeduplicationServiceTests.swift ================================================ // // MessageDeduplicationServiceTests.swift // bitchatTests // // Tests for MessageDeduplicationService, LRUDeduplicationCache, and ContentNormalizer. // This is free and unencumbered software released into the public domain. // import Testing import Foundation @testable import bitchat // MARK: - LRU Deduplication Cache Tests @Suite("LRU Deduplication Cache") @MainActor struct LRUDeduplicationCacheTests { // MARK: - Basic Operations @Test func emptyCache_containsReturnsFalse() { let cache = LRUDeduplicationCache(capacity: 10) #expect(!cache.contains("key")) #expect(cache.value(for: "key") == nil) #expect(cache.count == 0) } @Test func record_addsEntry() { let cache = LRUDeduplicationCache(capacity: 10) cache.record("key1", value: 42) #expect(cache.contains("key1")) #expect(cache.value(for: "key1") == 42) #expect(cache.count == 1) } @Test func record_updatesExistingEntry() { let cache = LRUDeduplicationCache(capacity: 10) cache.record("key1", value: 42) cache.record("key1", value: 100) #expect(cache.value(for: "key1") == 100) #expect(cache.count == 1) // Should not increase count } @Test func record_multipleEntries() { let cache = LRUDeduplicationCache(capacity: 10) cache.record("a", value: "alpha") cache.record("b", value: "beta") cache.record("c", value: "gamma") #expect(cache.count == 3) #expect(cache.value(for: "a") == "alpha") #expect(cache.value(for: "b") == "beta") #expect(cache.value(for: "c") == "gamma") } @Test func remove_removesEntry() { let cache = LRUDeduplicationCache(capacity: 10) cache.record("key1", value: 42) cache.record("key2", value: 100) cache.remove("key1") #expect(!cache.contains("key1")) #expect(cache.contains("key2")) } @Test func clear_removesAllEntries() { let cache = LRUDeduplicationCache(capacity: 10) cache.record("a", value: 1) cache.record("b", value: 2) cache.record("c", value: 3) cache.clear() #expect(cache.count == 0) #expect(!cache.contains("a")) #expect(!cache.contains("b")) #expect(!cache.contains("c")) } // MARK: - Eviction Tests @Test func eviction_removesOldestWhenOverCapacity() { let cache = LRUDeduplicationCache(capacity: 3) cache.record("a", value: 1) cache.record("b", value: 2) cache.record("c", value: 3) cache.record("d", value: 4) // Should evict "a" #expect(cache.count == 3) #expect(!cache.contains("a")) // Evicted #expect(cache.contains("b")) #expect(cache.contains("c")) #expect(cache.contains("d")) } @Test func eviction_maintainsCapacity() { let cache = LRUDeduplicationCache(capacity: 2) for i in 0..<100 { cache.record("key\(i)", value: i) } #expect(cache.count == 2) // Most recent entries should be present #expect(cache.contains("key99")) #expect(cache.contains("key98")) // Older entries should be evicted #expect(!cache.contains("key0")) #expect(!cache.contains("key50")) } @Test func eviction_capacityOfOne() { let cache = LRUDeduplicationCache(capacity: 1) cache.record("a", value: 1) cache.record("b", value: 2) #expect(cache.count == 1) #expect(!cache.contains("a")) #expect(cache.contains("b")) } @Test func eviction_skipsRemovedKeys() { let cache = LRUDeduplicationCache(capacity: 3) cache.record("a", value: 1) cache.record("b", value: 2) cache.record("c", value: 3) // Remove "a" manually cache.remove("a") // Add new entry - should evict "b" (next oldest still in map) cache.record("d", value: 4) // Cache should have b, c, d (a was removed) // Actually after eviction it should have c, d and maybe b depending on implementation #expect(!cache.contains("a")) #expect(cache.count <= 3) } // MARK: - Edge Cases @Test func emptyKey_works() { let cache = LRUDeduplicationCache(capacity: 10) cache.record("", value: 42) #expect(cache.contains("")) #expect(cache.value(for: "") == 42) } @Test func largeCapacity_works() { let cache = LRUDeduplicationCache(capacity: 10000) for i in 0..<5000 { cache.record("key\(i)", value: i) } #expect(cache.count == 5000) #expect(cache.contains("key0")) #expect(cache.contains("key4999")) } } // MARK: - Content Normalizer Tests struct ContentNormalizerTests { @Test func normalizedKey_basicContent() { let key1 = ContentNormalizer.normalizedKey("Hello World") let key2 = ContentNormalizer.normalizedKey("Hello World") #expect(key1 == key2) } @Test func normalizedKey_caseInsensitive() { let key1 = ContentNormalizer.normalizedKey("Hello World") let key2 = ContentNormalizer.normalizedKey("hello world") let key3 = ContentNormalizer.normalizedKey("HELLO WORLD") #expect(key1 == key2) #expect(key2 == key3) } @Test func normalizedKey_whitespaceCollapsed() { let key1 = ContentNormalizer.normalizedKey("Hello World") let key2 = ContentNormalizer.normalizedKey("Hello World") let key3 = ContentNormalizer.normalizedKey("Hello\t\nWorld") #expect(key1 == key2) #expect(key2 == key3) } @Test func normalizedKey_trimmed() { let key1 = ContentNormalizer.normalizedKey("Hello") let key2 = ContentNormalizer.normalizedKey(" Hello ") let key3 = ContentNormalizer.normalizedKey("\nHello\n") #expect(key1 == key2) #expect(key2 == key3) } @Test func normalizedKey_urlQueryStripped() { let key1 = ContentNormalizer.normalizedKey("Check https://example.com/page") let key2 = ContentNormalizer.normalizedKey("Check https://example.com/page?query=value") let key3 = ContentNormalizer.normalizedKey("Check https://example.com/page#anchor") #expect(key1 == key2) #expect(key2 == key3) } @Test func normalizedKey_httpAndHttpsDistinct() { // URL scheme is preserved let key1 = ContentNormalizer.normalizedKey("http://example.com/page") let key2 = ContentNormalizer.normalizedKey("https://example.com/page") #expect(key1 != key2) } @Test func normalizedKey_differentContent() { let key1 = ContentNormalizer.normalizedKey("Hello") let key2 = ContentNormalizer.normalizedKey("Goodbye") #expect(key1 != key2) } @Test func normalizedKey_returnsHashFormat() { let key = ContentNormalizer.normalizedKey("Test content") #expect(key.hasPrefix("h:")) #expect(key.count == 18) // "h:" + 16 hex chars } @Test func normalizedKey_emptyContent() { let key = ContentNormalizer.normalizedKey("") #expect(key.hasPrefix("h:")) } @Test func normalizedKey_longContentTruncated() { let longContent = String(repeating: "a", count: 10000) let key1 = ContentNormalizer.normalizedKey(longContent) let key2 = ContentNormalizer.normalizedKey(longContent + "extra") // Both should be the same since content is truncated before hashing #expect(key1 == key2) } @Test func normalizedKey_prefixLengthRespected() { let content = "Short" let key1 = ContentNormalizer.normalizedKey(content, prefixLength: 3) let key2 = ContentNormalizer.normalizedKey(content, prefixLength: 100) // Different prefix lengths may produce different keys // "sho" vs "short" #expect(key1 != key2) } @Test func normalizedKey_urlsInMiddleOfContent() { let content1 = "Check out https://example.com/path?query=1 for more info" let content2 = "Check out https://example.com/path for more info" let key1 = ContentNormalizer.normalizedKey(content1) let key2 = ContentNormalizer.normalizedKey(content2) #expect(key1 == key2) } @Test func normalizedKey_multipleUrls() { let content1 = "Links: https://a.com?x=1 and http://b.com#y" let content2 = "Links: https://a.com and http://b.com" let key1 = ContentNormalizer.normalizedKey(content1) let key2 = ContentNormalizer.normalizedKey(content2) #expect(key1 == key2) } } // MARK: - Message Deduplication Service Tests @Suite("Message Deduplication Service") @MainActor struct MessageDeduplicationServiceTests { // MARK: - Content Deduplication @Test func recordContent_storesTimestamp() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) let now = Date() service.recordContent("Hello World", timestamp: now) let retrieved = service.contentTimestamp(for: "Hello World") #expect(retrieved == now) } @Test func recordContent_updatesTimestamp() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) let early = Date(timeIntervalSince1970: 1000) let late = Date(timeIntervalSince1970: 2000) service.recordContent("Hello World", timestamp: early) service.recordContent("Hello World", timestamp: late) let retrieved = service.contentTimestamp(for: "Hello World") #expect(retrieved == late) } @Test func contentTimestamp_nilForUnseen() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) let timestamp = service.contentTimestamp(for: "Never seen") #expect(timestamp == nil) } @Test func recordContentKey_directKeyAccess() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) let now = Date() let key = service.normalizedContentKey("Test") service.recordContentKey(key, timestamp: now) #expect(service.contentTimestamp(forKey: key) == now) } @Test func normalizedContentKey_consistentWithNormalizer() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) let content = "Hello World" let serviceKey = service.normalizedContentKey(content) let normalizerKey = ContentNormalizer.normalizedKey(content) #expect(serviceKey == normalizerKey) } // MARK: - Nostr Event Deduplication @Test func recordNostrEvent_marksAsProcessed() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) #expect(!service.hasProcessedNostrEvent("event123")) service.recordNostrEvent("event123") #expect(service.hasProcessedNostrEvent("event123")) } @Test func hasProcessedNostrEvent_falseForUnseen() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) #expect(!service.hasProcessedNostrEvent("never-seen")) } @Test func nostrEvent_multipleEvents() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) service.recordNostrEvent("event1") service.recordNostrEvent("event2") service.recordNostrEvent("event3") #expect(service.hasProcessedNostrEvent("event1")) #expect(service.hasProcessedNostrEvent("event2")) #expect(service.hasProcessedNostrEvent("event3")) #expect(!service.hasProcessedNostrEvent("event4")) } // MARK: - Nostr ACK Deduplication @Test func recordNostrAck_marksAsProcessed() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) let ackKey = MessageDeduplicationService.ackKey( messageId: "msg123", ackType: "delivered", senderPubkey: "pubkey456" ) #expect(!service.hasProcessedNostrAck(ackKey)) service.recordNostrAck(ackKey) #expect(service.hasProcessedNostrAck(ackKey)) } @Test func ackKey_format() { let key = MessageDeduplicationService.ackKey( messageId: "msg", ackType: "read", senderPubkey: "pub" ) #expect(key == "msg:read:pub") } @Test func ackKey_differentComponents() { let key1 = MessageDeduplicationService.ackKey(messageId: "a", ackType: "delivered", senderPubkey: "x") let key2 = MessageDeduplicationService.ackKey(messageId: "a", ackType: "read", senderPubkey: "x") let key3 = MessageDeduplicationService.ackKey(messageId: "b", ackType: "delivered", senderPubkey: "x") #expect(key1 != key2) // Different ackType #expect(key1 != key3) // Different messageId } // MARK: - Clear Operations @Test func clearAll_clearsEverything() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) let now = Date() service.recordContent("Hello", timestamp: now) service.recordNostrEvent("event1") service.recordNostrAck("ack1") service.clearAll() #expect(service.contentTimestamp(for: "Hello") == nil) #expect(!service.hasProcessedNostrEvent("event1")) #expect(!service.hasProcessedNostrAck("ack1")) } @Test func clearNostrCaches_preservesContent() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) let now = Date() service.recordContent("Hello", timestamp: now) service.recordNostrEvent("event1") service.recordNostrAck("ack1") service.clearNostrCaches() #expect(service.contentTimestamp(for: "Hello") == now) // Preserved #expect(!service.hasProcessedNostrEvent("event1")) // Cleared #expect(!service.hasProcessedNostrAck("ack1")) // Cleared } // MARK: - Capacity Tests @Test func contentCache_respectsCapacity() { let service = MessageDeduplicationService(contentCapacity: 3, nostrEventCapacity: 100) service.recordContent("a", timestamp: Date()) service.recordContent("b", timestamp: Date()) service.recordContent("c", timestamp: Date()) service.recordContent("d", timestamp: Date()) // "a" should have been evicted #expect(service.contentTimestamp(for: "a") == nil) #expect(service.contentTimestamp(for: "d") != nil) } @Test func nostrEventCache_respectsCapacity() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 3) service.recordNostrEvent("e1") service.recordNostrEvent("e2") service.recordNostrEvent("e3") service.recordNostrEvent("e4") // "e1" should have been evicted #expect(!service.hasProcessedNostrEvent("e1")) #expect(service.hasProcessedNostrEvent("e4")) } // MARK: - Integration Tests @Test func realWorldDeduplication_similarMessages() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) let now = Date() // Record original message service.recordContent("Check out https://example.com/page?ref=abc", timestamp: now) // Same URL with different query params should match let timestamp = service.contentTimestamp(for: "Check out https://example.com/page?ref=xyz") #expect(timestamp == now) } @Test func realWorldDeduplication_caseVariations() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) let now = Date() service.recordContent("HELLO WORLD", timestamp: now) #expect(service.contentTimestamp(for: "hello world") == now) #expect(service.contentTimestamp(for: "Hello World") == now) } // MARK: - Thread Safety Tests (via @MainActor enforcement) @Test("Concurrent content recording is safe via MainActor") func concurrentContentRecording() async { let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000) let iterations = 100 // All operations run on MainActor due to @MainActor annotation // This test verifies the pattern works correctly await withTaskGroup(of: Void.self) { group in for i in 0..(capacity: 500) let iterations = 100 await withTaskGroup(of: Void.self) { group in // Write tasks for i in 0..(capacity: 10) let iterations = 100 await withTaskGroup(of: Void.self) { group in for i in 0.. hello #mesh https://example.com [\(message.formattedTimestamp)]") #expect(message.getCachedFormattedText(isDark: false, isSelf: false) != nil) } @MainActor @Test func formatMessage_systemMessageUsesSystemLayout() { let context = MockMessageFormattingContext(nickname: "carol") let message = BitchatMessage( id: "system-1", sender: "system", content: "connected", timestamp: Date(timeIntervalSince1970: 1_700_000_123), isRelay: false ) let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .dark) #expect(String(formatted.characters) == "* connected * [\(message.formattedTimestamp)]") #expect(message.getCachedFormattedText(isDark: true, isSelf: false) != nil) } @MainActor @Test func formatMessage_longSelfMessageFallsBackToPlainContentPath() { let context = MockMessageFormattingContext( nickname: "me", selfMessageIDs: ["self-1"] ) let longContent = String(repeating: "a", count: 4_500) let message = BitchatMessage( id: "self-1", sender: "me#cafe", content: longContent, timestamp: Date(timeIntervalSince1970: 1_700_000_456), isRelay: false ) let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light) #expect(String(formatted.characters) == "<@me#cafe> \(longContent) [\(message.formattedTimestamp)]") #expect(message.getCachedFormattedText(isDark: false, isSelf: true) != nil) } @MainActor @Test func formatMessage_mentionsAreRenderedThroughMentionFormatter() { let context = MockMessageFormattingContext(nickname: "carol") let message = BitchatMessage( id: "message-mention", sender: "alice", content: "hi @bob#a1b2", timestamp: Date(timeIntervalSince1970: 1_700_000_789), isRelay: false ) let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light) #expect(String(formatted.characters) == "<@alice> hi bob#a1b2 [\(message.formattedTimestamp)]") } @MainActor @Test func formatHeader_formatsNormalAndSystemSenders() { let context = MockMessageFormattingContext(nickname: "carol") let normalMessage = BitchatMessage( id: "header-1", sender: "alice#a1b2", content: "hello", timestamp: Date(timeIntervalSince1970: 1_700_001_000), isRelay: false ) let systemMessage = BitchatMessage( id: "header-2", sender: "system", content: "notice", timestamp: Date(timeIntervalSince1970: 1_700_001_111), isRelay: false ) let normalHeader = MessageFormattingEngine.formatHeader(normalMessage, context: context, colorScheme: .light) let systemHeader = MessageFormattingEngine.formatHeader(systemMessage, context: context, colorScheme: .dark) #expect(String(normalHeader.characters) == "<@alice#a1b2> ") #expect(String(systemHeader.characters) == "system") } // MARK: - Mention Extraction Tests @Test func extractMentions_singleMention() { let content = "Hello @alice how are you?" let mentions = MessageFormattingEngine.extractMentions(from: content) #expect(mentions == ["alice"]) } @Test func extractMentions_multipleMentions() { let content = "@alice and @bob are chatting with @charlie" let mentions = MessageFormattingEngine.extractMentions(from: content) #expect(mentions.count == 3) #expect(mentions.contains("alice")) #expect(mentions.contains("bob")) #expect(mentions.contains("charlie")) } @Test func extractMentions_mentionWithSuffix() { let content = "Hey @alice#a1b2 check this out" let mentions = MessageFormattingEngine.extractMentions(from: content) #expect(mentions == ["alice#a1b2"]) } @Test func extractMentions_noMentions() { let content = "Just a regular message with no mentions" let mentions = MessageFormattingEngine.extractMentions(from: content) #expect(mentions.isEmpty) } @Test func extractMentions_unicodeNickname() { let content = "Hello @日本語 and @émile" let mentions = MessageFormattingEngine.extractMentions(from: content) #expect(mentions.count == 2) #expect(mentions.contains("日本語")) #expect(mentions.contains("émile")) } @Test func extractMentions_mentionWithUnderscore() { let content = "Thanks @user_name_123" let mentions = MessageFormattingEngine.extractMentions(from: content) #expect(mentions == ["user_name_123"]) } @Test func extractMentions_emailNotCaptured() { // Email addresses should not be captured as mentions let content = "Contact me at test@example.com" let mentions = MessageFormattingEngine.extractMentions(from: content) // The regex will capture "example" after @ in email - this is expected behavior // as the regex doesn't distinguish email addresses #expect(mentions.count == 1) } // MARK: - Cashu Token Detection Tests @Test func containsCashuToken_validTokenA() { let content = "Here's a token: cashuAeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i" #expect(MessageFormattingEngine.containsCashuToken(content)) } @Test func containsCashuToken_validTokenB() { let content = "Payment: cashuBeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i" #expect(MessageFormattingEngine.containsCashuToken(content)) } @Test func containsCashuToken_noToken() { let content = "Just a regular message about cashews" #expect(!MessageFormattingEngine.containsCashuToken(content)) } @Test func containsCashuToken_tooShort() { let content = "Invalid: cashuAshort" #expect(!MessageFormattingEngine.containsCashuToken(content)) } // MARK: - Regex Pattern Tests @Test func hashtagPattern_standaloneHashtag() { let content = "#bitcoin is great" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range) #expect(matches.count == 1) } @Test func hashtagPattern_multipleHashtags() { let content = "#bitcoin #lightning #nostr" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range) #expect(matches.count == 3) } @Test func hashtagPattern_hashInMiddleOfWord() { let content = "test#notahashtag" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range) // This will match because the regex doesn't check for word boundaries #expect(matches.count == 1) } @Test func bolt11Pattern_mainnet() { let content = "Pay this: lnbc10u1pjexampleinvoice0000000000000000000000000000000000000000000" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range) #expect(matches.count == 1) } @Test func bolt11Pattern_testnet() { let content = "Test: lntb10u1pjexampleinvoice0000000000000000000000000000000000000000000" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range) #expect(matches.count == 1) } @Test func lnurlPattern_valid() { let content = "LNURL: lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserx" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.lnurl.matches(in: content, options: [], range: range) #expect(matches.count == 1) } @Test func lightningSchemePattern_valid() { let content = "Click: lightning:lnbc10u1example" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.lightningScheme.matches(in: content, options: [], range: range) #expect(matches.count == 1) } @Test func cashuPattern_valid() { let content = "Token: cashuAeyJwcm9vZnMiOlt7ImlkIjoiMDAwMDAwMDAwMDAwMDAwMCJ9XX0=" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.cashu.matches(in: content, options: [], range: range) #expect(matches.count == 1) } // MARK: - URL Detection Tests @Test func linkDetector_httpURL() { let content = "Check out http://example.com" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? [] #expect(matches.count == 1) } @Test func linkDetector_httpsURL() { let content = "Visit https://example.com/path?query=value" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? [] #expect(matches.count == 1) } @Test func linkDetector_multipleURLs() { let content = "See https://a.com and http://b.com" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? [] #expect(matches.count == 2) } // MARK: - String Extension Tests @Test func splitSuffix_withSuffix() { let name = "alice#a1b2" let (base, suffix) = name.splitSuffix() #expect(base == "alice") #expect(suffix == "#a1b2") } @Test func splitSuffix_withoutSuffix() { let name = "alice" let (base, suffix) = name.splitSuffix() #expect(base == "alice") #expect(suffix == "") } @Test func splitSuffix_withAtPrefix() { let name = "@alice#a1b2" let (base, suffix) = name.splitSuffix() #expect(base == "alice") #expect(suffix == "#a1b2") } @Test func hasVeryLongToken_noLongToken() { let content = "Short words only here" #expect(!content.hasVeryLongToken(threshold: 50)) } @Test func hasVeryLongToken_withLongToken() { let longToken = String(repeating: "a", count: 100) let content = "Here is a \(longToken) token" #expect(content.hasVeryLongToken(threshold: 50)) } @Test func hasVeryLongToken_exactThreshold() { let exactToken = String(repeating: "a", count: 50) let content = "Token: \(exactToken)" // Exactly at threshold DOES trigger (uses >= comparison) #expect(content.hasVeryLongToken(threshold: 50)) } } @MainActor private final class MockMessageFormattingContext: MessageFormattingContext { let nickname: String private let selfMessageIDs: Set private let peerURLs: [PeerID: URL] init( nickname: String, selfMessageIDs: Set = [], peerURLs: [PeerID: URL] = [:] ) { self.nickname = nickname self.selfMessageIDs = selfMessageIDs self.peerURLs = peerURLs } func isSelfMessage(_ message: BitchatMessage) -> Bool { selfMessageIDs.contains(message.id) } func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { .red } func peerURL(for peerID: PeerID) -> URL? { peerURLs[peerID] } } ================================================ FILE: bitchatTests/MimeTypeTests.swift ================================================ // // MimeTypeTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Testing import Foundation @testable import bitchat // MARK: - MimeType Mapping and Signature Tests struct MimeTypeTests { // MARK: MIME → Enum Parsing + Default Extension @Test(arguments: [ ("image/jpeg", MimeType.jpeg, "jpg"), ("image/jpg", MimeType.jpeg, "jpg"), ("image/png", MimeType.png, "png"), ("image/gif", MimeType.gif, "gif"), ("image/webp", MimeType.webp, "webp"), ("audio/mp4", MimeType.mp4Audio, "m4a"), ("audio/m4a", MimeType.m4a, "m4a"), ("audio/aac", MimeType.aac, "m4a"), ("audio/mpeg", MimeType.mpeg, "mp3"), ("audio/mp3", MimeType.mp3, "mp3"), ("audio/wav", MimeType.wav, "wav"), ("audio/x-wav", MimeType.xWav, "wav"), ("audio/ogg", MimeType.ogg, "ogg"), ("application/pdf", MimeType.pdf, "pdf"), ("application/octet-stream", MimeType.octetStream, "bin") ]) func mimeTypeParsingAndExtensions( mimeString: String, expectedType: MimeType, expectedExt: String ) throws { guard let mime = MimeType(mimeString) else { Issue.record("Failed to parse \(mimeString)") return } #expect(mime == expectedType, "Expected \(expectedType) for \(mimeString)") #expect(mime.mimeString == expectedType.mimeString) #expect(mime.defaultExtension == expectedExt) #expect(mime.isAllowed) } // MARK: - File Signature Validation @Test(arguments: [ // === Image types === (MimeType.jpeg, [0xFF, 0xD8, 0xFF]), (MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), (MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a" (MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP" // === Audio types === (MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3" (MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE" (MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS" // === Application types === (MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF" ]) func validSignatures(mime: MimeType, bytes: [UInt8]) throws { let data = Data(bytes) #expect(mime.matches(data: data), "Expected \(mime.mimeString) to match its signature") } // MARK: - Negative Tests @Test func invalidDataDoesNotMatch() throws { let badData = Data(repeating: 0x00, count: 16) for mime in MimeType.allCases where mime != .octetStream { #expect(!mime.matches(data: badData), "Unexpectedly matched \(mime.mimeString) with zeroed data") } } // MARK: - Octet-stream (generic binary) @Test func octetStreamAlwaysMatches() throws { let randomData = Data([0x00, 0x11, 0x22, 0x33]) #expect(MimeType.octetStream.matches(data: randomData), "application/octet-stream should always be considered valid") } } ================================================ FILE: bitchatTests/Mocks/MockBLEBus.swift ================================================ // // MockBLEBus.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation @testable import bitchat final class MockBLEBus { private var registry: [PeerID: MockBLEService] = [:] private var adjacency: [PeerID: Set] = [:] // Enable automatic flooding for public messages in integration tests only let autoFloodEnabled: Bool init(autoFloodEnabled: Bool = false) { self.autoFloodEnabled = autoFloodEnabled } func register(_ service: MockBLEService, for peerID: PeerID) { registry[peerID] = service if adjacency[peerID] == nil { adjacency[peerID] = [] } } func connect(_ a: PeerID, _ b: PeerID) { var setA = adjacency[a] ?? [] setA.insert(b) adjacency[a] = setA var setB = adjacency[b] ?? [] setB.insert(a) adjacency[b] = setB } func disconnect(_ a: PeerID, _ b: PeerID) { if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA } if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB } } func neighbors(of peerID: PeerID) -> [MockBLEService] { let ids = adjacency[peerID] ?? [] let result = ids.compactMap { registry[$0] } return result } func isDirectNeighbor(_ a: PeerID, _ b: PeerID) -> Bool { let res = adjacency[a]?.contains(b) ?? false return res } func service(for peerID: PeerID) -> MockBLEService? { let svc = registry[peerID] return svc } } ================================================ FILE: bitchatTests/Mocks/MockBLEService.swift ================================================ // // MockBLEService.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation import CoreBluetooth @testable import bitchat /// In-memory BLE test harness used by E2E/Integration tests. /// /// Design: /// - Global `registry` maps `peerID` -> service instance, and `adjacency` tracks /// simulated connections between peers. Tests call `simulateConnectedPeer` / /// `simulateDisconnectedPeer` to manage topology. /// - `resetTestBus()` clears global state and is called in test `setUp()`. /// - `messageDeliveryHandler` and `packetDeliveryHandler` let tests observe messages/packets /// as they flow, enabling scenarios like manual encryption/relay. /// - A thread-safe `seenMessageIDs` set prevents double-delivery races during flooding. /// /// Flooding: /// - `autoFloodEnabled` is disabled by default; Integration tests enable it in `setUp()` to /// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit /// relays when needed. final class MockBLEService: NSObject { private let bus: MockBLEBus // MARK: - Properties matching BLEService weak var delegate: BitchatDelegate? var myPeerID = PeerID(str: "MOCK1234") var myNickname: String = "MockUser" private let mockKeychain = MockKeychain() // 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: PeerID { return myPeerID } // MARK: - Initialization init(bus: MockBLEBus) { self.bus = bus } // MARK: - Methods matching BLEService func setNickname(_ nickname: String) { self.myNickname = nickname } // MARK: - In-memory test bus (for E2E/Integration) /// Registers this instance on first use. private func registerIfNeeded() { bus.register(self, for: myPeerID) } /// Returns adjacent neighbors based on the current simulated topology. private func neighbors() -> [MockBLEService] { bus.neighbors(of: myPeerID) } func startServices() { // Mock implementation - do nothing } func stopServices() { // Mock implementation - do nothing } func isPeerConnected(_ peerID: PeerID) -> Bool { return connectedPeers.contains(peerID) } func peerNickname(peerID: String) -> String? { "MockPeer_\(peerID)" } func getPeerNicknames() -> [PeerID: String] { var nicknames: [PeerID: String] = [:] for peer in connectedPeers { nicknames[peer] = "MockPeer_\(peer)" } return nicknames } func getPeers() -> [PeerID: String] { return getPeerNicknames() } /// Keep local echo synchronous so Swift Testing confirmations observe it deterministically. private func deliverLocalEcho(_ message: BitchatMessage) { delegate?.didReceiveMessage(message) } 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.id.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) deliverLocalEcho(message) // Surface raw packet to tests that intercept/relay/encrypt packetDeliveryHandler?(packet) // Deliver public messages to adjacent peers via bus if recipientID == nil { for neighbor in neighbors() { neighbor.simulateIncomingPacket(packet) } } } } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { // Tests currently ignore file transfer flows; keep stub for protocol conformance. } func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { // Tests currently ignore file transfer flows; keep stub for protocol conformance. } func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, 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.id.data(using: .utf8)!, recipientID: recipientPeerID.id.data(using: .utf8)!, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, ttl: 3 ) sentMessages.append((message, packet)) sentPackets.append(packet) deliverLocalEcho(message) // Surface raw packet to tests that intercept/relay/encrypt packetDeliveryHandler?(packet) // If directly connected to recipient, deliver only to them. if bus.isDirectNeighbor(myPeerID, recipientPeerID), let target = bus.service(for: recipientPeerID) { target.simulateIncomingPacket(packet) } else { // Not directly connected: deliver to neighbors for relay for neighbor in neighbors() where neighbor.peerID != recipientPeerID { neighbor.simulateIncomingPacket(packet) } } } } 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(keychain: mockKeychain) } func getFingerprint(for peerID: String) -> String? { return nil } // MARK: - Test Helper Methods func simulateConnectedPeer(_ peerID: PeerID) { registerIfNeeded() bus.connect(myPeerID, peerID) connectedPeers.insert(peerID) delegate?.didConnectToPeer(peerID) delegate?.didUpdatePeerList(Array(connectedPeers)) } func simulateDisconnectedPeer(_ peerID: PeerID) { bus.disconnect(myPeerID, peerID) connectedPeers.remove(peerID) delegate?.didDisconnectFromPeer(peerID) delegate?.didUpdatePeerList(Array(connectedPeers)) } func simulateIncomingMessage(_ message: BitchatMessage) { delegate?.didReceiveMessage(message) // Also surface via test handler for E2E/Integration messageDeliveryHandler?(message) } private var seenMessageIDs: Set = [] private let seenLock = NSLock() func simulateIncomingPacket(_ packet: BitchatPacket) { // Process through the actual handling logic if let message = BitchatMessage(packet.payload) { var shouldDeliver = false seenLock.lock() if !seenMessageIDs.contains(message.id) { seenMessageIDs.insert(message.id) shouldDeliver = true } seenLock.unlock() if shouldDeliver { delegate?.didReceiveMessage(message) // Also surface via test handler for E2E/Integration messageDeliveryHandler?(message) // Optional flooding for integration-style broadcast tests. // When enabled, propagate a public broadcast across the entire connected // component regardless of the original TTL to better emulate large-network // broadcast expectations. De-duplication via seenMessageIDs prevents loops. if bus.autoFloodEnabled, packet.recipientID == nil, !message.isPrivate { let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0 for neighbor in neighbors() { // Avoid immediate echo loopback to sender if known if let sender = message.senderPeerID, sender == neighbor.peerID { continue } var relay = packet relay.ttl = nextTTL neighbor.simulateIncomingPacket(relay) } } } } packetDeliveryHandler?(packet) } func getConnectedPeers() -> [PeerID] { return Array(connectedPeers) } // MARK: - Compatibility methods for old tests func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String? = nil) { sendPrivateMessage(content, to: recipientPeerID, recipientNickname: recipientNickname, messageID: messageID ?? UUID().uuidString) } } // Backward compatibility for older tests typealias MockSimplifiedBluetoothService = MockBLEService // MARK: - Helpers extension MockBLEService { convenience init(peerID: PeerID, nickname: String, bus: MockBLEBus) { self.init(bus: bus) myPeerID = peerID mockNickname = nickname } func simulateConnection(with otherPeer: MockBLEService) { simulateConnectedPeer(otherPeer.myPeerID) otherPeer.simulateConnectedPeer(myPeerID) } } ================================================ FILE: bitchatTests/Mocks/MockIdentityManager.swift ================================================ // // MockIdentityManager.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation @testable import bitchat final class MockIdentityManager: SecureIdentityStateManagerProtocol { private let keychain: KeychainManagerProtocol private var blockedFingerprints: Set = [] private var blockedNostrPubkeys: Set = [] private var socialIdentities: [String: SocialIdentity] = [:] init(_ keychain: KeychainManagerProtocol) { self.keychain = keychain } func loadIdentityCache() {} func saveIdentityCache() {} func forceSave() {} func getSocialIdentity(for fingerprint: String) -> SocialIdentity? { socialIdentities[fingerprint] } func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {} func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] { [] } func updateSocialIdentity(_ identity: SocialIdentity) { socialIdentities[identity.fingerprint] = identity if identity.isBlocked { blockedFingerprints.insert(identity.fingerprint) } else { blockedFingerprints.remove(identity.fingerprint) } } func getFavorites() -> Set { Set() } func setFavorite(_ fingerprint: String, isFavorite: Bool) {} func isFavorite(fingerprint: String) -> Bool { false } func isBlocked(fingerprint: String) -> Bool { blockedFingerprints.contains(fingerprint) || socialIdentities[fingerprint]?.isBlocked == true } func setBlocked(_ fingerprint: String, isBlocked: Bool) { if var identity = socialIdentities[fingerprint] { identity.isBlocked = isBlocked socialIdentities[fingerprint] = identity } else { let identity = SocialIdentity( fingerprint: fingerprint, localPetname: nil, claimedNickname: "", trustLevel: .unknown, isFavorite: false, isBlocked: isBlocked, notes: nil ) socialIdentities[fingerprint] = identity } if isBlocked { blockedFingerprints.insert(fingerprint) } else { blockedFingerprints.remove(fingerprint) } } func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { blockedNostrPubkeys.contains(pubkeyHexLowercased) } func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { if isBlocked { blockedNostrPubkeys.insert(pubkeyHexLowercased) } else { blockedNostrPubkeys.remove(pubkeyHexLowercased) } } func getBlockedNostrPubkeys() -> Set { blockedNostrPubkeys } func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {} func updateHandshakeState(peerID: PeerID, state: HandshakeState) {} func clearAllIdentityData() {} func removeEphemeralSession(peerID: PeerID) {} func setVerified(fingerprint: String, verified: Bool) {} func isVerified(fingerprint: String) -> Bool { true } func getVerifiedFingerprints() -> Set { Set() } } ================================================ FILE: bitchatTests/Mocks/MockKeychain.swift ================================================ // // MockKeychain.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation @testable import bitchat final class MockKeychain: KeychainManagerProtocol { private var storage: [String: Data] = [:] private var serviceStorage: [String: [String: Data]] = [:] // BCH-01-009: Configurable error simulation for testing var simulatedReadError: KeychainReadResult? var simulatedSaveError: KeychainSaveResult? func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { storage[key] = keyData return true } func getIdentityKey(forKey key: String) -> Data? { storage[key] } func deleteIdentityKey(forKey key: String) -> Bool { storage.removeValue(forKey: key) return true } func deleteAllKeychainData() -> Bool { storage.removeAll() serviceStorage.removeAll() return true } func secureClear(_ data: inout Data) { data = Data() } func secureClear(_ string: inout String) { string = "" } func verifyIdentityKeyExists() -> Bool { storage["identity_noiseStaticKey"] != nil } // BCH-01-009: New methods with proper error classification func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { if let simulated = simulatedReadError { return simulated } if let data = storage[key] { return .success(data) } return .itemNotFound } func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { if let simulated = simulatedSaveError { return simulated } storage[key] = keyData return .success } // MARK: - Generic Data Storage (consolidated from KeychainHelper) func save(key: String, data: Data, service: String, accessible: CFString?) { if serviceStorage[service] == nil { serviceStorage[service] = [:] } serviceStorage[service]?[key] = data } func load(key: String, service: String) -> Data? { serviceStorage[service]?[key] } func delete(key: String, service: String) { serviceStorage[service]?.removeValue(forKey: key) } } /// Typealias for backwards compatibility with tests using MockKeychainHelper typealias MockKeychainHelper = MockKeychain /// Mock keychain that tracks secureClear calls for testing DH secret clearing final class TrackingMockKeychain: KeychainManagerProtocol { private var storage: [String: Data] = [:] private var serviceStorage: [String: [String: Data]] = [:] /// Thread-safe counter for secureClear calls private let lock = NSLock() private var _secureClearDataCallCount = 0 private var _secureClearStringCallCount = 0 // BCH-01-009: Configurable error simulation for testing var simulatedReadError: KeychainReadResult? var simulatedSaveError: KeychainSaveResult? var secureClearDataCallCount: Int { lock.lock() defer { lock.unlock() } return _secureClearDataCallCount } var secureClearStringCallCount: Int { lock.lock() defer { lock.unlock() } return _secureClearStringCallCount } var totalSecureClearCallCount: Int { return secureClearDataCallCount + secureClearStringCallCount } func resetCounts() { lock.lock() defer { lock.unlock() } _secureClearDataCallCount = 0 _secureClearStringCallCount = 0 } func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { storage[key] = keyData return true } func getIdentityKey(forKey key: String) -> Data? { storage[key] } func deleteIdentityKey(forKey key: String) -> Bool { storage.removeValue(forKey: key) return true } func deleteAllKeychainData() -> Bool { storage.removeAll() serviceStorage.removeAll() return true } func secureClear(_ data: inout Data) { lock.lock() _secureClearDataCallCount += 1 lock.unlock() data = Data() } func secureClear(_ string: inout String) { lock.lock() _secureClearStringCallCount += 1 lock.unlock() string = "" } func verifyIdentityKeyExists() -> Bool { storage["identity_noiseStaticKey"] != nil } // BCH-01-009: New methods with proper error classification func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { if let simulated = simulatedReadError { return simulated } if let data = storage[key] { return .success(data) } return .itemNotFound } func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { if let simulated = simulatedSaveError { return simulated } storage[key] = keyData return .success } func save(key: String, data: Data, service: String, accessible: CFString?) { if serviceStorage[service] == nil { serviceStorage[service] = [:] } serviceStorage[service]?[key] = data } func load(key: String, service: String) -> Data? { serviceStorage[service]?[key] } func delete(key: String, service: String) { serviceStorage[service]?.removeValue(forKey: key) } } ================================================ FILE: bitchatTests/Mocks/MockTransport.swift ================================================ // // MockTransport.swift // bitchatTests // // Mock Transport implementation for unit testing ChatViewModel. // This is free and unencumbered software released into the public domain. // import Foundation import Combine import CoreBluetooth @testable import bitchat /// Mock Transport implementation for testing ChatViewModel in isolation. /// Records all method calls and allows test code to verify interactions. final class MockTransport: Transport { // MARK: - Protocol Properties weak var delegate: BitchatDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? var myPeerID: PeerID = PeerID(str: "TESTPEER") var myNickname: String = "TestUser" private let peerSnapshotSubject = CurrentValueSubject<[TransportPeerSnapshot], Never>([]) var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { peerSnapshotSubject.eraseToAnyPublisher() } // MARK: - Recording Properties (for test assertions) private(set) var sentMessages: [(content: String, mentions: [String], messageID: String?, timestamp: Date?)] = [] private(set) var sentPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String, messageID: String)] = [] private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = [] private(set) var sentDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = [] private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = [] private(set) var cancelledTransfers: [String] = [] private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var startServicesCallCount = 0 private(set) var stopServicesCallCount = 0 private(set) var emergencyDisconnectCallCount = 0 private(set) var broadcastAnnounceCallCount = 0 private(set) var triggeredHandshakes: [PeerID] = [] // MARK: - Configurable Mock State var connectedPeers: Set = [] var reachablePeers: Set = [] var peerNicknames: [PeerID: String] = [:] var peerFingerprints: [PeerID: String] = [:] var peerNoiseStates: [PeerID: LazyHandshakeState] = [:] private let mockKeychain = MockKeychain() // MARK: - Transport Protocol Implementation func currentPeerSnapshots() -> [TransportPeerSnapshot] { peerSnapshotSubject.value } func setNickname(_ nickname: String) { myNickname = nickname } func startServices() { startServicesCallCount += 1 } func stopServices() { stopServicesCallCount += 1 } func emergencyDisconnectAll() { emergencyDisconnectCallCount += 1 connectedPeers.removeAll() reachablePeers.removeAll() } func isPeerConnected(_ peerID: PeerID) -> Bool { connectedPeers.contains(peerID) } func isPeerReachable(_ peerID: PeerID) -> Bool { reachablePeers.contains(peerID) || connectedPeers.contains(peerID) } func peerNickname(peerID: PeerID) -> String? { peerNicknames[peerID] } func getPeerNicknames() -> [PeerID: String] { peerNicknames } func getFingerprint(for peerID: PeerID) -> String? { peerFingerprints[peerID] } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { peerNoiseStates[peerID] ?? .none } func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) } func getNoiseService() -> NoiseEncryptionService { NoiseEncryptionService(keychain: mockKeychain) } // MARK: - Messaging func sendMessage(_ content: String, mentions: [String]) { sentMessages.append((content, mentions, nil, nil)) } func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { sentMessages.append((content, mentions, messageID, timestamp)) } func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { sentPrivateMessages.append((content, peerID, recipientNickname, messageID)) } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { sentReadReceipts.append((receipt, peerID)) } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { sentFavoriteNotifications.append((peerID, isFavorite)) } func sendBroadcastAnnounce() { broadcastAnnounceCallCount += 1 } func sendDeliveryAck(for messageID: String, to peerID: PeerID) { sentDeliveryAcks.append((messageID, peerID)) } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { sentBroadcastFiles.append((packet, transferId)) } func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { sentPrivateFiles.append((packet, peerID, transferId)) } func cancelTransfer(_ transferId: String) { cancelledTransfers.append(transferId) } func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA)) } func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { sentVerifyResponses.append((peerID, noiseKeyHex, nonceA)) } // MARK: - Test Helpers /// Clears all recorded method calls for fresh assertions func resetRecordings() { sentMessages.removeAll() sentPrivateMessages.removeAll() sentReadReceipts.removeAll() sentDeliveryAcks.removeAll() sentFavoriteNotifications.removeAll() sentBroadcastFiles.removeAll() sentPrivateFiles.removeAll() cancelledTransfers.removeAll() sentVerifyChallenges.removeAll() sentVerifyResponses.removeAll() startServicesCallCount = 0 stopServicesCallCount = 0 emergencyDisconnectCallCount = 0 broadcastAnnounceCallCount = 0 triggeredHandshakes.removeAll() } /// Simulates a peer connecting func simulateConnect(_ peerID: PeerID, nickname: String? = nil) { connectedPeers.insert(peerID) if let nickname = nickname { peerNicknames[peerID] = nickname } delegate?.didConnectToPeer(peerID) delegate?.didUpdatePeerList(Array(connectedPeers)) publishPeerSnapshots() } /// Simulates a peer disconnecting func simulateDisconnect(_ peerID: PeerID) { connectedPeers.remove(peerID) peerNicknames.removeValue(forKey: peerID) delegate?.didDisconnectFromPeer(peerID) delegate?.didUpdatePeerList(Array(connectedPeers)) publishPeerSnapshots() } /// Simulates receiving a message func simulateIncomingMessage(_ message: BitchatMessage) { delegate?.didReceiveMessage(message) } /// Simulates receiving a public message func simulateIncomingPublicMessage( from peerID: PeerID, nickname: String, content: String, timestamp: Date = Date(), messageID: String? = nil ) { delegate?.didReceivePublicMessage( from: peerID, nickname: nickname, content: content, timestamp: timestamp, messageID: messageID ) } /// Simulates Bluetooth state change func simulateBluetoothStateChange(_ state: CBManagerState) { delegate?.didUpdateBluetoothState(state) } /// Updates the peer snapshot publisher func updatePeerSnapshots(_ snapshots: [TransportPeerSnapshot]) { peerSnapshotSubject.send(snapshots) Task { @MainActor [weak self] in self?.peerEventsDelegate?.didUpdatePeerSnapshots(snapshots) } } private func publishPeerSnapshots() { let now = Date() let snapshots = connectedPeers.map { peerID in TransportPeerSnapshot( peerID: peerID, nickname: peerNicknames[peerID] ?? "", isConnected: true, noisePublicKey: Data(hexString: peerID.bare), lastSeen: now ) } updatePeerSnapshots(snapshots) } } ================================================ FILE: bitchatTests/Noise/NoiseCoverageTests.swift ================================================ import CryptoKit import Foundation import Testing @testable import bitchat @Suite("Noise Coverage Tests") struct NoiseCoverageTests { private let keychain = MockKeychain() private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey() private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey() private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey() private let alicePeerID = PeerID(str: "0011223344556677") private let bobPeerID = PeerID(str: "8899aabbccddeeff") private let charliePeerID = PeerID(str: "fedcba9876543210") @Test("Protocol metadata and handshake patterns expose expected values") func protocolMetadataAndHandshakePatterns() { let ikName = NoiseProtocolName(pattern: NoisePattern.IK.patternName) #expect(ikName.pattern == "IK") #expect(ikName.dh == "25519") #expect(ikName.cipher == "ChaChaPoly") #expect(ikName.hash == "SHA256") #expect(ikName.fullName == "Noise_IK_25519_ChaChaPoly_SHA256") #expect(NoisePattern.XX.patternName == "XX") #expect(NoisePattern.IK.patternName == "IK") #expect(NoisePattern.NK.patternName == "NK") let ikPatterns = NoisePattern.IK.messagePatterns #expect(ikPatterns.count == 2) #expect(ikPatterns[0] == [.e, .es, .s, .ss]) #expect(ikPatterns[1] == [.e, .ee, .se]) let nkPatterns = NoisePattern.NK.messagePatterns #expect(nkPatterns.count == 2) #expect(nkPatterns[0] == [.e, .es]) #expect(nkPatterns[1] == [.e, .ee]) } @Test("Symmetric state supports long protocol names and mixKeyAndHash") func symmetricStateLongNameAndMixKeyAndHash() { let longName = String(repeating: "NoiseProtocol_", count: 3) let symmetricState = NoiseSymmetricState(protocolName: longName) let initialHash = symmetricState.getHandshakeHash() #expect(initialHash.count == 32) #expect(!symmetricState.hasCipherKey()) symmetricState.mixKeyAndHash(Data("input-key-material".utf8)) #expect(symmetricState.hasCipherKey()) #expect(symmetricState.getHandshakeHash() != initialHash) } @Test("Cipher state rejects duplicate and stale extracted nonces") func cipherStateRejectsDuplicateAndStaleNonces() throws { let key = SymmetricKey(size: .bits256) let receiver = NoiseCipherState(key: key, useExtractedNonce: true) let initialPayload = try makeExtractedNoncePayload( key: key, nonce: 0, plaintext: Data("nonce-0".utf8) ) let initialPlaintext = try receiver.decrypt(ciphertext: initialPayload) #expect(initialPlaintext == Data("nonce-0".utf8)) #expect(throws: (any Error).self) { try receiver.decrypt(ciphertext: initialPayload) } for nonce in 1...1024 { let payload = try makeExtractedNoncePayload( key: key, nonce: UInt64(nonce), plaintext: Data("nonce-\(nonce)".utf8) ) let plaintext = try receiver.decrypt(ciphertext: payload) #expect(plaintext == Data("nonce-\(nonce)".utf8)) } #expect(throws: (any Error).self) { try receiver.decrypt(ciphertext: initialPayload) } } @Test("Cipher state handles large nonce jumps and associated-data mismatches") func cipherStateHandlesLargeJumpsAndAADMismatch() throws { let key = SymmetricKey(size: .bits256) let extractedReceiver = NoiseCipherState(key: key, useExtractedNonce: true) let jumped = try makeExtractedNoncePayload( key: key, nonce: 1500, plaintext: Data("future".utf8) ) let slightlyOlder = try makeExtractedNoncePayload( key: key, nonce: 1499, plaintext: Data("older".utf8) ) let tooOld = try makeExtractedNoncePayload( key: key, nonce: 100, plaintext: Data("ancient".utf8) ) #expect(try extractedReceiver.decrypt(ciphertext: jumped) == Data("future".utf8)) #expect(try extractedReceiver.decrypt(ciphertext: slightlyOlder) == Data("older".utf8)) #expect(throws: (any Error).self) { try extractedReceiver.decrypt(ciphertext: tooOld) } let sender = NoiseCipherState(key: key) let receiver = NoiseCipherState(key: key) let plaintext = Data("associated-data".utf8) let aad = Data("good-aad".utf8) let ciphertext = try sender.encrypt(plaintext: plaintext, associatedData: aad) #expect(throws: (any Error).self) { try receiver.decrypt(ciphertext: ciphertext, associatedData: Data("bad-aad".utf8)) } #expect(try receiver.decrypt(ciphertext: ciphertext, associatedData: aad) == plaintext) #expect(throws: (any Error).self) { try receiver.decrypt(ciphertext: Data(repeating: 0xAA, count: 15)) } } @Test("Cipher state covers nonce guard rails and extracted payload bounds") func cipherStateCoversNonceGuardRailsAndExtractedPayloadBounds() throws { let uninitializedCipher = NoiseCipherState() #expect(throws: NoiseError.uninitializedCipher) { try uninitializedCipher.encrypt(plaintext: Data("missing-key".utf8)) } #expect(throws: NoiseError.uninitializedCipher) { try uninitializedCipher.decrypt(ciphertext: Data(repeating: 0x00, count: 16)) } #expect(try uninitializedCipher.extractNonceFromCiphertextPayloadForTesting(Data([0x00, 0x01, 0x02])) == nil) let key = SymmetricKey(size: .bits256) let highNonceCipher = NoiseCipherState(key: key) highNonceCipher.setNonceForTesting(1_000_000_001) #expect(throws: Never.self) { _ = try highNonceCipher.encrypt(plaintext: Data("high-nonce".utf8)) } let exhaustedCipher = NoiseCipherState(key: key) exhaustedCipher.setNonceForTesting(UInt64(UInt32.max)) #expect(throws: NoiseError.nonceExceeded) { try exhaustedCipher.encrypt(plaintext: Data("nonce-limit".utf8)) } } @Test("Handshake validation rejects malformed keys and messages") func handshakeValidationRejectsMalformedInputs() throws { let responder = NoiseHandshakeState( role: .responder, pattern: .XX, keychain: keychain, localStaticKey: bobStaticKey ) #expect(throws: (any Error).self) { try responder.readMessage(Data(repeating: 0x00, count: 31)) } let invalidKeys = [ Data(), Data(repeating: 0x00, count: 32), Data([0x01] + Array(repeating: 0x00, count: 31)), Data(repeating: 0xFF, count: 32), ] for invalidKey in invalidKeys { #expect(throws: (any Error).self) { _ = try NoiseHandshakeState.validatePublicKey(invalidKey) } } let valid = aliceStaticKey.publicKey.rawRepresentation let roundTripped = try NoiseHandshakeState.validatePublicKey(valid) #expect(roundTripped.rawRepresentation == valid) let initiator = NoiseHandshakeState( role: .initiator, pattern: .XX, keychain: keychain, localStaticKey: aliceStaticKey ) let responderForTamper = NoiseHandshakeState( role: .responder, pattern: .XX, keychain: keychain, localStaticKey: bobStaticKey ) let message1 = try initiator.writeMessage() _ = try responderForTamper.readMessage(message1) var message2 = try responderForTamper.writeMessage() message2[40] ^= 0x01 #expect(throws: (any Error).self) { try initiator.readMessage(message2) } } @Test("Handshake readers reject invalid ephemeral and truncated static payloads") func handshakeReadersRejectInvalidEphemeralAndTruncatedStaticPayloads() throws { let invalidEphemeralResponder = NoiseHandshakeState( role: .responder, pattern: .XX, keychain: keychain, localStaticKey: bobStaticKey ) #expect(throws: NoiseError.invalidMessage) { try invalidEphemeralResponder.readMessage(Data(repeating: 0x00, count: 32)) } let truncatedStaticInitiator = NoiseHandshakeState( role: .initiator, pattern: .XX, keychain: keychain, localStaticKey: aliceStaticKey ) _ = try truncatedStaticInitiator.writeMessage() let responderEphemeralOnly = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation #expect(throws: NoiseError.invalidMessage) { try truncatedStaticInitiator.readMessage(responderEphemeralOnly) } } @Test("IK handshake completes and supports transport messages") func ikHandshakeCompletesAndSupportsTransportMessages() throws { let initiator = NoiseHandshakeState( role: .initiator, pattern: .IK, keychain: keychain, localStaticKey: aliceStaticKey, remoteStaticKey: bobStaticKey.publicKey ) let responder = NoiseHandshakeState( role: .responder, pattern: .IK, keychain: keychain, localStaticKey: bobStaticKey ) let outboundPayload = Data("ik-outbound".utf8) let returnPayload = Data("ik-return".utf8) let message1 = try initiator.writeMessage(payload: outboundPayload) #expect(try responder.readMessage(message1) == outboundPayload) let message2 = try responder.writeMessage(payload: returnPayload) #expect(try initiator.readMessage(message2) == returnPayload) #expect(initiator.isHandshakeComplete()) #expect(responder.isHandshakeComplete()) let (initiatorSend, initiatorReceive, initiatorHash) = try initiator.getTransportCiphers( useExtractedNonce: true ) let (responderSend, responderReceive, responderHash) = try responder.getTransportCiphers( useExtractedNonce: true ) #expect(initiatorHash == responderHash) let clientCiphertext = try initiatorSend.encrypt(plaintext: Data("ik-transport".utf8)) #expect(try responderReceive.decrypt(ciphertext: clientCiphertext) == Data("ik-transport".utf8)) let serverCiphertext = try responderSend.encrypt(plaintext: Data("ik-response".utf8)) #expect(try initiatorReceive.decrypt(ciphertext: serverCiphertext) == Data("ik-response".utf8)) } @Test("NK handshake requires a responder static key and supports transport messages") func nkHandshakeRequiresStaticAndSupportsTransportMessages() throws { let missingStaticInitiator = NoiseHandshakeState( role: .initiator, pattern: .NK, keychain: keychain, localStaticKey: aliceStaticKey ) #expect(throws: (any Error).self) { try missingStaticInitiator.writeMessage() } let initiator = NoiseHandshakeState( role: .initiator, pattern: .NK, keychain: keychain, localStaticKey: aliceStaticKey, remoteStaticKey: bobStaticKey.publicKey ) let responder = NoiseHandshakeState( role: .responder, pattern: .NK, keychain: keychain, localStaticKey: bobStaticKey ) let outboundPayload = Data("nk-outbound".utf8) let returnPayload = Data("nk-return".utf8) let message1 = try initiator.writeMessage(payload: outboundPayload) #expect(try responder.readMessage(message1) == outboundPayload) let message2 = try responder.writeMessage(payload: returnPayload) #expect(try initiator.readMessage(message2) == returnPayload) #expect(initiator.isHandshakeComplete()) #expect(responder.isHandshakeComplete()) let (initiatorSend, initiatorReceive, initiatorHash) = try initiator.getTransportCiphers( useExtractedNonce: true ) let (responderSend, responderReceive, responderHash) = try responder.getTransportCiphers( useExtractedNonce: true ) #expect(initiatorHash == responderHash) let clientCiphertext = try initiatorSend.encrypt(plaintext: Data("nk-transport".utf8)) #expect(try responderReceive.decrypt(ciphertext: clientCiphertext) == Data("nk-transport".utf8)) let serverCiphertext = try responderSend.encrypt(plaintext: Data("nk-response".utf8)) #expect(try initiatorReceive.decrypt(ciphertext: serverCiphertext) == Data("nk-response".utf8)) } @Test("Responder-side NK writes require peer ephemeral input") func responderWritesRequirePeerEphemeralInput() { let nkResponder = NoiseHandshakeState( role: .responder, pattern: .NK, keychain: keychain, localStaticKey: bobStaticKey ) #expect(throws: NoiseError.missingKeys) { try nkResponder.writeMessage() } } @Test("Direct DH helpers reject missing keys across all patterns") func directDHHelpersRejectMissingKeysAcrossAllPatterns() throws { let eeState = NoiseHandshakeState( role: .initiator, pattern: .XX, keychain: keychain, localStaticKey: aliceStaticKey ) #expect(throws: NoiseError.missingKeys) { try eeState.performDHOperationForTesting(.ee) } let esInitiator = NoiseHandshakeState( role: .initiator, pattern: .XX, keychain: keychain, localStaticKey: aliceStaticKey ) #expect(throws: NoiseError.missingKeys) { try esInitiator.performDHOperationForTesting(.es) } let esResponder = NoiseHandshakeState( role: .responder, pattern: .XX, keychain: keychain, localStaticKey: nil ) #expect(throws: NoiseError.missingKeys) { try esResponder.performDHOperationForTesting(.es) } let seInitiator = NoiseHandshakeState( role: .initiator, pattern: .XX, keychain: keychain, localStaticKey: nil ) #expect(throws: NoiseError.missingKeys) { try seInitiator.performDHOperationForTesting(.se) } let seResponder = NoiseHandshakeState( role: .responder, pattern: .XX, keychain: keychain, localStaticKey: bobStaticKey ) #expect(throws: NoiseError.missingKeys) { try seResponder.performDHOperationForTesting(.se) } let ssState = NoiseHandshakeState( role: .initiator, pattern: .XX, keychain: keychain, localStaticKey: nil ) #expect(throws: NoiseError.missingKeys) { try ssState.performDHOperationForTesting(.ss) } #expect(throws: Never.self) { try eeState.performDHOperationForTesting(.e) try eeState.performDHOperationForTesting(.s) } } @Test("Prepared handshake writers cover remaining missing-key branches") func preparedHandshakeWritersCoverRemainingMissingKeyBranches() { let eeResponder = NoiseHandshakeState( role: .responder, pattern: .NK, keychain: keychain, localStaticKey: bobStaticKey ) eeResponder.setCurrentPatternForTesting(1) #expect(throws: NoiseError.missingKeys) { try eeResponder.writeMessage() } let seInitiator = NoiseHandshakeState( role: .initiator, pattern: .XX, keychain: keychain, localStaticKey: aliceStaticKey ) seInitiator.setCurrentPatternForTesting(2) #expect(throws: NoiseError.missingKeys) { try seInitiator.writeMessage() } let seResponder = NoiseHandshakeState( role: .responder, pattern: .IK, keychain: keychain, localStaticKey: bobStaticKey ) seResponder.setCurrentPatternForTesting(1) seResponder.setRemoteEphemeralPublicKeyForTesting(Curve25519.KeyAgreement.PrivateKey().publicKey) #expect(throws: NoiseError.missingKeys) { try seResponder.writeMessage() } } @Test("Completed handshakes reject additional reads and writes") func completedHandshakesRejectAdditionalReadsAndWrites() throws { let initiator = NoiseHandshakeState( role: .initiator, pattern: .IK, keychain: keychain, localStaticKey: aliceStaticKey, remoteStaticKey: bobStaticKey.publicKey ) let responder = NoiseHandshakeState( role: .responder, pattern: .IK, keychain: keychain, localStaticKey: bobStaticKey ) let message1 = try initiator.writeMessage(payload: Data("first".utf8)) _ = try responder.readMessage(message1) let message2 = try responder.writeMessage(payload: Data("second".utf8)) _ = try initiator.readMessage(message2) #expect(throws: NoiseError.handshakeComplete) { try initiator.writeMessage() } #expect(throws: NoiseError.handshakeComplete) { try responder.readMessage(message1) } } @Test("XX final message requires a local static key") func xxFinalMessageRequiresLocalStaticKey() throws { let initiator = NoiseHandshakeState( role: .initiator, pattern: .XX, keychain: keychain, localStaticKey: nil ) let responder = NoiseHandshakeState( role: .responder, pattern: .XX, keychain: keychain, localStaticKey: bobStaticKey ) let message1 = try initiator.writeMessage() _ = try responder.readMessage(message1) let message2 = try responder.writeMessage() _ = try initiator.readMessage(message2) #expect(throws: (any Error).self) { try initiator.writeMessage() } } @Test("Responder start handshake is empty and transport ciphers require completion") func responderStartHandshakeAndIncompleteTransportCiphers() throws { let responderSession = NoiseSession( peerID: bobPeerID, role: .responder, keychain: keychain, localStaticKey: bobStaticKey ) let incompleteHandshake = NoiseHandshakeState( role: .initiator, pattern: .XX, keychain: keychain, localStaticKey: aliceStaticKey ) #expect(try responderSession.startHandshake().isEmpty) #expect(responderSession.getState() == .handshaking) #expect(throws: (any Error).self) { _ = try incompleteHandshake.getTransportCiphers(useExtractedNonce: true) } } @Test("Session manager callbacks establish and failed handshakes clean up state") func sessionManagerCallbacksAndFailureCleanup() async throws { let establishedRecorder = SessionCallbackRecorder() let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:) bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) let didEstablish = await TestHelpers.waitUntil( { establishedRecorder.establishedCount == 2 }, timeout: 0.5 ) #expect(didEstablish) #expect(establishedRecorder.establishedPeerIDs.contains(alicePeerID)) #expect(establishedRecorder.establishedPeerIDs.contains(bobPeerID)) let failureRecorder = SessionCallbackRecorder() let failingManager = NoiseSessionManager(localStaticKey: charlieStaticKey, keychain: keychain) failingManager.onSessionFailed = failureRecorder.recordFailure(peerID:error:) #expect(throws: (any Error).self) { try failingManager.handleIncomingHandshake( from: charliePeerID, message: Data(repeating: 0x00, count: 31) ) } let didFail = await TestHelpers.waitUntil( { failureRecorder.failureCount == 1 }, timeout: 0.5 ) #expect(didFail) #expect(failingManager.getSession(for: charliePeerID) == nil) } @Test("Session manager cleans up initiator sessions after start-handshake failures") func sessionManagerCleansUpInitiatorSessionsAfterStartHandshakeFailures() { let manager = NoiseSessionManager( localStaticKey: aliceStaticKey, keychain: keychain, sessionFactory: { peerID, role in FailingNoiseSession( peerID: peerID, role: role, keychain: self.keychain, localStaticKey: self.aliceStaticKey ) } ) #expect(throws: FailingNoiseSession.Error.synthetic) { try manager.initiateHandshake(with: alicePeerID) } #expect(manager.getSession(for: alicePeerID) == nil) } @Test("Session manager rekeys established sessions and replaces partial handshakes") func sessionManagerRekeysAndReplacesSessions() throws { let manager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) #expect(throws: NoiseSessionError.sessionNotFound) { try manager.encrypt(Data("missing".utf8), for: alicePeerID) } #expect(throws: NoiseSessionError.sessionNotFound) { try manager.decrypt(Data("missing".utf8), from: alicePeerID) } let initialHandshake = try manager.initiateHandshake(with: alicePeerID) #expect(!initialHandshake.isEmpty) let firstSession = try #require(manager.getSession(for: alicePeerID)) let restartedHandshake = try manager.initiateHandshake(with: alicePeerID) let restartedSession = try #require(manager.getSession(for: alicePeerID)) #expect(!restartedHandshake.isEmpty) #expect(restartedSession !== firstSession) let restartedInitiator = NoiseSession( peerID: alicePeerID, role: .initiator, keychain: keychain, localStaticKey: bobStaticKey ) let replacementMessage = try restartedInitiator.startHandshake() let replacementResponse = try manager.handleIncomingHandshake( from: alicePeerID, message: replacementMessage ) let replacementSession = try #require(manager.getSession(for: alicePeerID)) #expect(replacementResponse != nil) #expect(replacementSession !== restartedSession) let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) let establishedSession = try #require( aliceManager.getSession(for: alicePeerID) as? SecureNoiseSession ) establishedSession.setMessageCountForTesting( UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9) ) let sessionsNeedingRekey = aliceManager.getSessionsNeedingRekey() #expect(sessionsNeedingRekey.contains { $0.peerID == alicePeerID && $0.needsRekey }) #expect(throws: NoiseSessionError.alreadyEstablished) { try aliceManager.initiateHandshake(with: alicePeerID) } try aliceManager.initiateRekey(for: alicePeerID) let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID)) #expect(rekeyedSession !== establishedSession) #expect(rekeyedSession.getState() == .handshaking) } @Test("Secure noise sessions enforce limits and renegotiation thresholds") func secureNoiseSessionsEnforceLimitsAndThresholds() throws { let initiator = SecureNoiseSession( peerID: alicePeerID, role: .initiator, keychain: keychain, localStaticKey: aliceStaticKey ) let responder = SecureNoiseSession( peerID: bobPeerID, role: .responder, keychain: keychain, localStaticKey: bobStaticKey ) try establishSessions(initiator: initiator, responder: responder) responder.setMessageCountForTesting(0) responder.setLastActivityTimeForTesting(Date()) #expect(!responder.needsRenegotiation()) responder.setMessageCountForTesting( UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9) ) #expect(responder.needsRenegotiation()) responder.setMessageCountForTesting(0) responder.setLastActivityTimeForTesting( Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1)) ) #expect(responder.needsRenegotiation()) initiator.setMessageCountForTesting(NoiseSecurityConstants.maxMessagesPerSession) #expect(throws: (any Error).self) { try initiator.encrypt(Data("exhausted".utf8)) } initiator.setMessageCountForTesting(0) #expect(throws: (any Error).self) { try initiator.encrypt(Data(repeating: 0xAB, count: NoiseSecurityConstants.maxMessageSize + 1)) } responder.setLastActivityTimeForTesting(Date()) #expect(throws: (any Error).self) { try responder.decrypt( Data(repeating: 0xCD, count: NoiseSecurityConstants.maxMessageSize + 1) ) } let transportCiphertext = try initiator.encrypt(Data("secure-session".utf8)) #expect(try responder.decrypt(transportCiphertext) == Data("secure-session".utf8)) } @Test("Secure noise sessions expire based on session start time") func secureNoiseSessionsExpireBasedOnSessionStartTime() throws { let initiator = SecureNoiseSession( peerID: alicePeerID, role: .initiator, keychain: keychain, localStaticKey: aliceStaticKey ) let responder = SecureNoiseSession( peerID: bobPeerID, role: .responder, keychain: keychain, localStaticKey: bobStaticKey ) try establishSessions(initiator: initiator, responder: responder) initiator.setSessionStartTimeForTesting( Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1)) ) #expect(throws: (any Error).self) { try initiator.encrypt(Data("expired".utf8)) } responder.setSessionStartTimeForTesting( Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1)) ) #expect(throws: (any Error).self) { try responder.decrypt(Data()) } } @Test("Rate limiter handles global message caps and per-peer resets") func rateLimiterGlobalMessageCapAndReset() async throws { let globalLimiter = NoiseRateLimiter() for index in 0.. Data { var fullNonce = Data(count: 12) withUnsafeBytes(of: nonce.littleEndian) { bytes in fullNonce.replaceSubrange(4..<12, with: bytes) } let sealedBox = try ChaChaPoly.seal( plaintext, using: key, nonce: ChaChaPoly.Nonce(data: fullNonce), authenticating: associatedData ) return extractedNoncePrefix(nonce) + sealedBox.ciphertext + sealedBox.tag } private func extractedNoncePrefix(_ nonce: UInt64) -> Data { withUnsafeBytes(of: nonce.bigEndian) { bytes in Data(bytes.suffix(4)) } } } private final class SessionCallbackRecorder: @unchecked Sendable { private let lock = NSLock() private var establishedEntries: [(PeerID, Data)] = [] private var failureEntries: [(PeerID, String)] = [] var establishedCount: Int { lock.lock() defer { lock.unlock() } return establishedEntries.count } var failureCount: Int { lock.lock() defer { lock.unlock() } return failureEntries.count } var establishedPeerIDs: [PeerID] { lock.lock() defer { lock.unlock() } return establishedEntries.map(\.0) } func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) { lock.lock() establishedEntries.append((peerID, remoteKey.rawRepresentation)) lock.unlock() } func recordFailure(peerID: PeerID, error: Error) { lock.lock() failureEntries.append((peerID, String(describing: error))) lock.unlock() } } private final class FailingNoiseSession: NoiseSession { enum Error: Swift.Error { case synthetic } override func startHandshake() throws -> Data { throw Error.synthetic } } ================================================ FILE: bitchatTests/Noise/NoiseProtocolTests.swift ================================================ // // NoiseProtocolTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import CryptoKit import Foundation import Testing @testable import bitchat // MARK: - Test Vector Support struct NoiseTestVector: Codable { let protocol_name: String let init_prologue: String let init_static: String let init_ephemeral: String let init_psks: [String]? let resp_prologue: String let resp_static: String let resp_ephemeral: String let resp_psks: [String]? let handshake_hash: String? let messages: [TestMessage] struct TestMessage: Codable { let payload: String let ciphertext: String } } extension Data { init?(hex: String) { let cleaned = hex.replacingOccurrences(of: " ", with: "") guard cleaned.count % 2 == 0 else { return nil } var data = Data(capacity: cleaned.count / 2) var index = cleaned.startIndex while index < cleaned.endIndex { let nextIndex = cleaned.index(index, offsetBy: 2) guard let byte = UInt8(cleaned[index.. String { map { String(format: "%02x", $0) }.joined() } } struct NoiseProtocolTests { private let aliceKey = Curve25519.KeyAgreement.PrivateKey() private let bobKey = Curve25519.KeyAgreement.PrivateKey() private let mockKeychain = MockKeychain() private let alicePeerID = PeerID(str: UUID().uuidString) private let bobPeerID = PeerID(str: UUID().uuidString) private let aliceSession: NoiseSession private let bobSession: NoiseSession init() { aliceSession = NoiseSession( peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey ) bobSession = NoiseSession( peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey ) } // MARK: - Basic Handshake Tests @Test func xxPatternHandshake() throws { // Alice starts handshake (message 1) let message1 = try aliceSession.startHandshake() #expect(!message1.isEmpty) #expect(aliceSession.getState() == .handshaking) // Bob processes message 1 and creates message 2 let message2 = try bobSession.processHandshakeMessage(message1) #expect(message2 != nil) #expect(!message2!.isEmpty) #expect(bobSession.getState() == .handshaking) // Alice processes message 2 and creates message 3 let message3 = try aliceSession.processHandshakeMessage(message2!) #expect(message3 != nil) #expect(!message3!.isEmpty) #expect(aliceSession.getState() == .established) // Bob processes message 3 and completes handshake let finalMessage = try bobSession.processHandshakeMessage(message3!) #expect(finalMessage == nil) // No more messages needed #expect(bobSession.getState() == .established) // Verify both sessions are established #expect(aliceSession.isEstablished()) #expect(bobSession.isEstablished()) // Verify they have each other's static keys #expect( aliceSession.getRemoteStaticPublicKey()?.rawRepresentation == bobKey.publicKey.rawRepresentation) #expect( bobSession.getRemoteStaticPublicKey()?.rawRepresentation == aliceKey.publicKey.rawRepresentation) } @Test func handshakeStateValidation() throws { // Cannot process message before starting handshake #expect(throws: NoiseSessionError.invalidState) { try aliceSession.processHandshakeMessage(Data()) } // Start handshake _ = try aliceSession.startHandshake() // Cannot start handshake twice #expect(throws: NoiseSessionError.invalidState) { try aliceSession.startHandshake() } } // MARK: - Encryption/Decryption Tests @Test func basicEncryptionDecryption() throws { try performHandshake(initiator: aliceSession, responder: bobSession) let plaintext = "Hello, Bob!".data(using: .utf8)! // Alice encrypts let ciphertext = try aliceSession.encrypt(plaintext) #expect(ciphertext != plaintext) #expect(ciphertext.count > plaintext.count) // Should have overhead // Bob decrypts let decrypted = try bobSession.decrypt(ciphertext) #expect(decrypted == plaintext) } @Test func bidirectionalEncryption() throws { try performHandshake(initiator: aliceSession, responder: bobSession) // Alice -> Bob let aliceMessage = "Hello from Alice".data(using: .utf8)! let aliceCiphertext = try aliceSession.encrypt(aliceMessage) let bobReceived = try bobSession.decrypt(aliceCiphertext) #expect(bobReceived == aliceMessage) // Bob -> Alice let bobMessage = "Hello from Bob".data(using: .utf8)! let bobCiphertext = try bobSession.encrypt(bobMessage) let aliceReceived = try aliceSession.decrypt(bobCiphertext) #expect(aliceReceived == bobMessage) } @Test func largeMessageEncryption() throws { try performHandshake(initiator: aliceSession, responder: bobSession) // Create a large message let largeMessage = TestHelpers.generateRandomData(length: 100_000) // Encrypt and decrypt let ciphertext = try aliceSession.encrypt(largeMessage) let decrypted = try bobSession.decrypt(ciphertext) #expect(decrypted == largeMessage) } @Test func encryptionBeforeHandshake() { let plaintext = "test".data(using: .utf8)! #expect(throws: NoiseSessionError.notEstablished) { try aliceSession.encrypt(plaintext) } #expect(throws: NoiseSessionError.notEstablished) { try aliceSession.decrypt(plaintext) } } // MARK: - Session Manager Tests @Test func sessionManagerBasicOperations() throws { let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) #expect(manager.getSession(for: alicePeerID) == nil) _ = try manager.initiateHandshake(with: alicePeerID) #expect(manager.getSession(for: alicePeerID) != nil) // Get session let retrieved = manager.getSession(for: alicePeerID) #expect(retrieved != nil) // Remove session manager.removeSession(for: alicePeerID) #expect(manager.getSession(for: alicePeerID) == nil) } @Test func sessionManagerHandshakeInitiation() throws { let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) // Initiate handshake let handshakeData = try manager.initiateHandshake(with: alicePeerID) #expect(!handshakeData.isEmpty) // Session should exist let session = manager.getSession(for: alicePeerID) #expect(session != nil) #expect(session?.getState() == .handshaking) } @Test func sessionManagerIncomingHandshake() throws { let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Alice initiates let message1 = try aliceManager.initiateHandshake(with: alicePeerID) // Bob responds let message2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1) #expect(message2 != nil) // Continue handshake let message3 = try aliceManager.handleIncomingHandshake( from: alicePeerID, message: message2!) #expect(message3 != nil) // Complete handshake let finalMessage = try bobManager.handleIncomingHandshake( from: bobPeerID, message: message3!) #expect(finalMessage == nil) // Both should have established sessions #expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true) #expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true) } @Test func sessionManagerEncryptionDecryption() throws { let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Establish sessions try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) // Encrypt with manager let plaintext = "Test message".data(using: .utf8)! let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID) // Decrypt with manager let decrypted = try bobManager.decrypt(ciphertext, from: bobPeerID) #expect(decrypted == plaintext) } // MARK: - Security Tests @Test func tamperedCiphertextDetection() throws { try performHandshake(initiator: aliceSession, responder: bobSession) let plaintext = "Secret message".data(using: .utf8)! var ciphertext = try aliceSession.encrypt(plaintext) // Tamper with ciphertext ciphertext[ciphertext.count / 2] ^= 0xFF // Decryption should fail if #available(macOS 14.4, iOS 17.4, *) { #expect(throws: CryptoKitError.authenticationFailure) { try bobSession.decrypt(ciphertext) } } else { #expect(throws: (any Error).self) { try bobSession.decrypt(ciphertext) } } } @Test func replayPrevention() throws { try performHandshake(initiator: aliceSession, responder: bobSession) let plaintext = "Test message".data(using: .utf8)! let ciphertext = try aliceSession.encrypt(plaintext) // First decryption should succeed _ = try bobSession.decrypt(ciphertext) // Replaying the same ciphertext should fail #expect(throws: NoiseError.replayDetected) { try bobSession.decrypt(ciphertext) } } @Test func sessionIsolation() throws { // Create two separate session pairs let aliceSession1 = NoiseSession( peerID: PeerID(str: "peer1"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let bobSession1 = NoiseSession( peerID: PeerID(str: "alice1"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey) let aliceSession2 = NoiseSession( peerID: PeerID(str: "peer2"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let bobSession2 = NoiseSession( peerID: PeerID(str: "alice2"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey) // Establish both pairs try performHandshake(initiator: aliceSession1, responder: bobSession1) try performHandshake(initiator: aliceSession2, responder: bobSession2) // Encrypt with session 1 let plaintext = "Secret".data(using: .utf8)! let ciphertext1 = try aliceSession1.encrypt(plaintext) // Should not be able to decrypt with session 2 if #available(macOS 14.4, iOS 17.4, *) { #expect(throws: CryptoKitError.authenticationFailure) { try bobSession2.decrypt(ciphertext1) } } else { #expect(throws: (any Error).self) { try bobSession2.decrypt(ciphertext1) } } // But should work with correct session let decrypted = try bobSession1.decrypt(ciphertext1) #expect(decrypted == plaintext) } // MARK: - Session Recovery Tests @Test func peerRestartDetection() throws { // Establish initial sessions let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) // Exchange some messages to establish nonce state let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID) _ = try bobManager.decrypt(message1, from: bobPeerID) let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID) _ = try aliceManager.decrypt(message2, from: alicePeerID) // Simulate Bob restart by creating new manager with same key let bobManagerRestarted = NoiseSessionManager( localStaticKey: bobKey, keychain: mockKeychain) // Bob initiates new handshake after restart let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID) // Alice should accept the new handshake (clearing old session) let newHandshake2 = try aliceManager.handleIncomingHandshake( from: alicePeerID, message: newHandshake1) #expect(newHandshake2 != nil) // Complete the new handshake let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake( from: bobPeerID, message: newHandshake2!) #expect(newHandshake3 != nil) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) // Should be able to exchange messages with new sessions let testMessage = "After restart".data(using: .utf8)! let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID) let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID) #expect(decrypted == testMessage) } @Test func nonceDesynchronizationRecovery() throws { // Create two sessions let aliceSession = NoiseSession( peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let bobSession = NoiseSession( peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey) // Establish sessions try performHandshake(initiator: aliceSession, responder: bobSession) // Exchange messages to advance nonces for i in 0..<5 { let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!) _ = try bobSession.decrypt(msg) } // Simulate desynchronization by encrypting but not decrypting for i in 0..<3 { _ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!) } // With per-packet nonce carried, decryption should not throw here let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!) #expect(throws: Never.self) { try bobSession.decrypt(desyncMessage) } } @Test func concurrentEncryption() async throws { // Test thread safety of encryption operations let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) let messageCount = 100 try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in var encryptedMessages: [Int: Data] = [:] // Encrypt messages sequentially to avoid nonce races in manager for i in 0.. [NoiseTestVector] { // Try to load from test bundle let testBundle = Bundle(for: MockKeychain.self) guard let url = testBundle.url(forResource: "NoiseTestVectors", withExtension: "json") else { throw NSError( domain: "NoiseTests", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Could not find NoiseTestVectors.json in test bundle" ]) } let data = try Data(contentsOf: url) return try JSONDecoder().decode([NoiseTestVector].self, from: data) } private func runTestVector(_ testVector: NoiseTestVector) throws { // Parse test inputs guard let initStatic = Data(hex: testVector.init_static), let initEphemeral = Data(hex: testVector.init_ephemeral), let respStatic = Data(hex: testVector.resp_static), let respEphemeral = Data(hex: testVector.resp_ephemeral), let prologue = Data(hex: testVector.init_prologue) else { throw NSError( domain: "NoiseTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to parse test vector hex strings"]) } let expectedHash = testVector.handshake_hash.flatMap { Data(hex: $0) } // Create keys guard let initStaticKey = try? Curve25519.KeyAgreement.PrivateKey( rawRepresentation: initStatic), let initEphemeralKey = try? Curve25519.KeyAgreement.PrivateKey( rawRepresentation: initEphemeral), let respStaticKey = try? Curve25519.KeyAgreement.PrivateKey( rawRepresentation: respStatic), let respEphemeralKey = try? Curve25519.KeyAgreement.PrivateKey( rawRepresentation: respEphemeral) else { throw NSError( domain: "NoiseTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to create keys from test vectors"]) } let keychain = MockKeychain() // Create handshake states let initiatorHandshake = NoiseHandshakeState( role: .initiator, pattern: .XX, keychain: keychain, localStaticKey: initStaticKey, prologue: prologue, predeterminedEphemeralKey: initEphemeralKey ) let responderHandshake = NoiseHandshakeState( role: .responder, pattern: .XX, keychain: keychain, localStaticKey: respStaticKey, prologue: prologue, predeterminedEphemeralKey: respEphemeralKey ) // For XX pattern, we have 3 handshake messages, then transport messages // The test vector messages are ordered as: [msg1, msg2, msg3, transport1, transport2, ...] guard testVector.messages.count >= 3 else { throw NSError( domain: "NoiseTests", code: 5, userInfo: [NSLocalizedDescriptionKey: "Test vector must have at least 3 messages for XX pattern"]) } // Message 1: Initiator -> Responder (e) guard let payload1 = Data(hex: testVector.messages[0].payload), let expectedCiphertext1 = Data(hex: testVector.messages[0].ciphertext) else { throw NSError( domain: "NoiseTests", code: 4, userInfo: [NSLocalizedDescriptionKey: "Message 1: Failed to parse hex"]) } let msg1 = try initiatorHandshake.writeMessage(payload: payload1) #expect(!msg1.isEmpty, "Message 1 should not be empty") #expect(msg1 == expectedCiphertext1, "Message 1 ciphertext should match expected value. Got: \(msg1.hexString()), Expected: \(expectedCiphertext1.hexString())") let decrypted1 = try responderHandshake.readMessage(msg1) #expect(decrypted1 == payload1, "Message 1: Decrypted payload should match original") // Message 2: Responder -> Initiator (e, ee, s, es) guard let payload2 = Data(hex: testVector.messages[1].payload), let expectedCiphertext2 = Data(hex: testVector.messages[1].ciphertext) else { throw NSError( domain: "NoiseTests", code: 4, userInfo: [NSLocalizedDescriptionKey: "Message 2: Failed to parse hex"]) } let msg2 = try responderHandshake.writeMessage(payload: payload2) #expect(!msg2.isEmpty, "Message 2 should not be empty") #expect(msg2 == expectedCiphertext2, "Message 2 ciphertext should match expected value. Got: \(msg2.hexString()), Expected: \(expectedCiphertext2.hexString())") let decrypted2 = try initiatorHandshake.readMessage(msg2) #expect(decrypted2 == payload2, "Message 2: Decrypted payload should match original") // Message 3: Initiator -> Responder (s, se) guard let payload3 = Data(hex: testVector.messages[2].payload), let expectedCiphertext3 = Data(hex: testVector.messages[2].ciphertext) else { throw NSError( domain: "NoiseTests", code: 4, userInfo: [NSLocalizedDescriptionKey: "Message 3: Failed to parse hex"]) } let msg3 = try initiatorHandshake.writeMessage(payload: payload3) #expect(!msg3.isEmpty, "Message 3 should not be empty") #expect(msg3 == expectedCiphertext3, "Message 3 ciphertext should match expected value. Got: \(msg3.hexString()), Expected: \(expectedCiphertext3.hexString())") let decrypted3 = try responderHandshake.readMessage(msg3) #expect(decrypted3 == payload3, "Message 3: Decrypted payload should match original") // Verify handshake hash let initiatorHash = initiatorHandshake.getHandshakeHash() let responderHash = responderHandshake.getHandshakeHash() #expect(initiatorHash == responderHash, "Initiator and responder hashes should match") if let expectedHash = expectedHash { #expect( initiatorHash == expectedHash, "Handshake hash should match expected value from test vector. Got: \(initiatorHash.hexString()), Expected: \(expectedHash.hexString())") } // Get transport ciphers let (initSend, initRecv, _) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false) let (respSend, respRecv, _) = try responderHandshake.getTransportCiphers(useExtractedNonce: false) // Test transport messages (messages after the 3 handshake messages) for index in 3..= expectedMinimumCalls, "Expected at least \(expectedMinimumCalls) secureClear calls for DH secrets, got \(trackingKeychain.secureClearDataCallCount)" ) } @Test func encryptionWorksAfterSecureClear() throws { // Verify that encryption/decryption still works correctly after adding secureClear let trackingKeychain = TrackingMockKeychain() let aliceKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey() let alice = NoiseSession( peerID: PeerID(str: "alice-test-enc"), role: .initiator, keychain: trackingKeychain, localStaticKey: aliceKey ) let bob = NoiseSession( peerID: PeerID(str: "bob-test-enc"), role: .responder, keychain: trackingKeychain, localStaticKey: bobKey ) // Perform handshake let msg1 = try alice.startHandshake() let msg2 = try bob.processHandshakeMessage(msg1)! let msg3 = try alice.processHandshakeMessage(msg2)! _ = try bob.processHandshakeMessage(msg3) // Verify both sessions are established #expect(alice.isEstablished()) #expect(bob.isEstablished()) // Verify secureClear was called (basic sanity check) #expect(trackingKeychain.secureClearDataCallCount > 0) // Test encryption from Alice to Bob let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)! let ciphertext1 = try alice.encrypt(plaintext1) let decrypted1 = try bob.decrypt(ciphertext1) #expect(decrypted1 == plaintext1) // Test encryption from Bob to Alice let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)! let ciphertext2 = try bob.encrypt(plaintext2) let decrypted2 = try alice.decrypt(ciphertext2) #expect(decrypted2 == plaintext2) // Test multiple messages to verify cipher state is correct for i in 1...10 { let msg = "Message \(i) from Alice".data(using: .utf8)! let cipher = try alice.encrypt(msg) let dec = try bob.decrypt(cipher) #expect(dec == msg) } } @Test func secureClearCalledInBothWriteAndReadPaths() throws { // Verify secureClear is called in both writeMessage and readMessage paths // We do this by checking the count increases at each step let aliceKeychain = TrackingMockKeychain() let bobKeychain = TrackingMockKeychain() let aliceKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey() let alice = NoiseSession( peerID: PeerID(str: "alice-paths"), role: .initiator, keychain: aliceKeychain, localStaticKey: aliceKey ) let bob = NoiseSession( peerID: PeerID(str: "bob-paths"), role: .responder, keychain: bobKeychain, localStaticKey: bobKey ) // Message 1: Alice writes (e token only, no DH) let msg1 = try alice.startHandshake() let aliceCountAfterMsg1 = aliceKeychain.secureClearDataCallCount // No DH in message 1 for initiator #expect(aliceCountAfterMsg1 == 0, "No DH secrets in message 1 write") // Bob reads message 1 (no DH) and writes message 2 (ee, es DH operations) let msg2 = try bob.processHandshakeMessage(msg1)! let bobCountAfterMsg2 = bobKeychain.secureClearDataCallCount // Bob should have cleared secrets for: ee (read), es (read), ee (write), es (write) #expect(bobCountAfterMsg2 >= 2, "Bob should clear DH secrets when processing/writing message 2") // Alice reads message 2 (ee, es) and writes message 3 (se) let msg3 = try alice.processHandshakeMessage(msg2)! let aliceCountAfterMsg3 = aliceKeychain.secureClearDataCallCount // Alice should have cleared: ee (read), es (read), se (write) #expect(aliceCountAfterMsg3 >= 3, "Alice should clear DH secrets when processing/writing message 3") // Bob reads message 3 (se) _ = try bob.processHandshakeMessage(msg3) let bobFinalCount = bobKeychain.secureClearDataCallCount // Bob should have additionally cleared: se (read) #expect(bobFinalCount > bobCountAfterMsg2, "Bob should clear DH secrets when processing message 3") } } ================================================ FILE: bitchatTests/Noise/NoiseRateLimiterTests.swift ================================================ import XCTest @testable import bitchat final class NoiseRateLimiterTests: XCTestCase { func test_allowHandshake_blocksAfterPerPeerLimit() { let limiter = NoiseRateLimiter() let peerID = makePeerID(1) for _ in 0.. PeerID { PeerID(str: String(format: "%016x", value)) } } ================================================ FILE: bitchatTests/Noise/NoiseTestVectors.json ================================================ [ { "protocol_name": "Noise_XX_25519_ChaChaPoly_SHA256", "init_prologue": "4a6f686e2047616c74", "init_static": "e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1", "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", "resp_prologue": "4a6f686e2047616c74", "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", "handshake_hash": "c8e5f64e846193be2a834104c2a009868d6c9f3bd3c186299888b488b2f1f58e", "messages": [ { "payload": "4c756477696720766f6e204d69736573", "ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79444c756477696720766f6e204d69736573" }, { "payload": "4d757272617920526f746862617264", "ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f14480884381cbad1f276e038c48378ffce2b65285e08d6b68aaa3629a5a8639392490e5b9bd5269c2f1e4f488ed8831161f19b7815528f8982ffe09be9b5c412f8a0db50f8814c7194e83f23dbd8d162c9326ad" }, { "payload": "462e20412e20486179656b", "ciphertext": "c7195ffacac1307ff99046f219750fc47693e23c3cb08b89c2af808b444850a80ae475b9df0f169ae80a89be0865b57f58c9fea0d4ec82a286427402f113e4b6ae769a1d95941d49b25030" }, { "payload": "4361726c204d656e676572", "ciphertext": "96763ed773f8e47bb3712f0e29b3060ffc956ffc146cee53d5e1df" }, { "payload": "4a65616e2d426170746973746520536179", "ciphertext": "3e40f15f6f3a46ae446b253bf8b1d9ffb6ed9b174d272328ff91a7e2e5c79c07f5" }, { "payload": "457567656e2042f6686d20766f6e2042617765726b", "ciphertext": "eb3f3515110702e047a6c9da4478b6ead94873c11c0f2d710ddb3f09fce024b3a58502ae3f" } ] }, { "protocol_name": "Noise_XX_25519_ChaChaPoly_SHA256", "init_prologue": "5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e", "init_psks": [], "init_static": "7dec208517a3b81a2861d7a71266d5d6dc944c5a8816634a86fe63198a0148ee", "init_ephemeral": "a32daf21e93c0131495ce1d903181fde81cc46937daaeb990bae7c992709421e", "resp_prologue": "5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e", "resp_psks": [], "resp_static": "4d0aed5098e3b4ef20357e9f686ce66204c792b358da2e475017d6c485304881", "resp_ephemeral": "4eece0f195d026db035ff987597c429d3ad3bcc2944df37d649528951b2a27c5", "messages": [ { "payload": "d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34", "ciphertext": "f9fa868ba97ab8a2686deccfaad5a484ee10a5bb85e3d1dce015a84797f92818d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34" }, { "payload": "d8190a92f7dc0c93dbea9118ba8055751fb7c6590c416ffbd419964132b99a85", "ciphertext": "8c4e6fdb7d09d501a86f7eca5c234522751706ed409182c05cdf5f827d4dae47b81c6c5f43b025692c24391eefee725c17d8cb0fbe3e4abb8aedf42c4fd2592d4ea48ac08989d6ae8b4adae08b2c34087c808c7aa55a63c02b0fab9e930612336bd43eaea04d3c670a0a146691aa9cc9d357872320dc735dbc48580cffb553db" }, { "payload": "77891b19dcb92ef7c055b672c4a5aa7fdf1c84146b8b303459022729473ce254", "ciphertext": "933ca6b5ed60df3df66121f0ab49a09e49efa45c613a86a3cecbf4c535cef2f83f72b42837b18e3572f2fdc2b74c331e2368a545cef54bdca081678ab0e9dd5348122459e0c034c851984d88ce610963d43cde6cfe73a67fbd5a63e8bfca96d0" }, { "payload": "d7efdf988072881941db045a42882433817555128fbf5663e56081712ec7d212", "ciphertext": "54ef0ff0629e1aaa7685a2806ab111cba76b52331f2642276736f415868eacb69ab2577f3bda0cbf72f879685f6ed25f" }, { "payload": "dd7bf01a588bafb52c6cfba952e5d8fe35cc2b3f92b4730ae2474615157345ce", "ciphertext": "356be70f110306d5c699bb834bb9d58d909e325924dfbec972e406e6f294dc63e1daebefe8a62a334facc8048ab4ad66" } ] } ] ================================================ FILE: bitchatTests/Nostr/GeoRelayDirectoryTests.swift ================================================ import Foundation import Tor import XCTest @testable import bitchat @MainActor final class GeoRelayDirectoryTests: XCTestCase { func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() { let csv = """ relay url,lat,lon wss://one.example/,10,20 https://one.example,10,20 http://two.example/,11,21 invalid row ws://three.example,not-a-lat,22 """ let parsed = Set(GeoRelayDirectory.parseCSV(csv)) XCTAssertEqual( parsed, Set([ GeoRelayDirectory.Entry(host: "one.example", lat: 10, lon: 20), GeoRelayDirectory.Entry(host: "two.example", lat: 11, lon: 21) ]) ) } func test_closestRelays_sortsByDistanceForLatLonAndGeohash() { let harness = makeHarness( cacheCSV: """ relay url,lat,lon close.example,37.7749,-122.4194 medium.example,34.0522,-118.2437 far.example,40.7128,-74.0060 """ ) let directory = GeoRelayDirectory(dependencies: harness.dependencies) XCTAssertEqual( directory.closestRelays(toLat: 37.78, lon: -122.41, count: 2), ["wss://close.example", "wss://medium.example"] ) XCTAssertEqual( directory.closestRelays(toLat: 37.78, lon: -122.41, count: 10), ["wss://close.example", "wss://medium.example", "wss://far.example"] ) let geohash = Geohash.encode(latitude: 37.78, longitude: -122.41, precision: 6) XCTAssertEqual( directory.closestRelays(toGeohash: geohash, count: 2), ["wss://close.example", "wss://medium.example"] ) } func test_loadLocalEntries_prefersCacheThenBundleThenWorkingDirectory() { let cacheHarness = makeHarness( cacheCSV: """ relay url,lat,lon cache.example,1,1 """, bundleCSV: """ relay url,lat,lon bundle.example,2,2 """, workingDirectoryCSV: """ relay url,lat,lon cwd.example,3,3 """ ) XCTAssertEqual( GeoRelayDirectory(dependencies: cacheHarness.dependencies).entries, [GeoRelayDirectory.Entry(host: "cache.example", lat: 1, lon: 1)] ) let bundleHarness = makeHarness( cacheCSV: "invalid", bundleCSV: """ relay url,lat,lon bundle.example,2,2 """, workingDirectoryCSV: """ relay url,lat,lon cwd.example,3,3 """ ) XCTAssertEqual( GeoRelayDirectory(dependencies: bundleHarness.dependencies).entries, [GeoRelayDirectory.Entry(host: "bundle.example", lat: 2, lon: 2)] ) let cwdHarness = makeHarness( cacheCSV: nil, bundleCSV: "invalid", workingDirectoryCSV: """ relay url,lat,lon cwd.example,3,3 """ ) XCTAssertEqual( GeoRelayDirectory(dependencies: cwdHarness.dependencies).entries, [GeoRelayDirectory.Entry(host: "cwd.example", lat: 3, lon: 3)] ) } func test_prefetchIfNeeded_skipsWhenFetchIntervalHasNotElapsed() async { let harness = makeHarness(fetchCSV: """ relay url,lat,lon one.example,1,1 """) harness.userDefaults.set(harness.clock.now, forKey: "georelay.lastFetchAt") let directory = GeoRelayDirectory(dependencies: harness.dependencies) directory.prefetchIfNeeded() try? await Task.sleep(nanoseconds: 20_000_000) let requestCount = await harness.fetcher.recordedRequestCount() XCTAssertEqual(requestCount, 0) XCTAssertFalse(directory.debugHasRetryTask) } func test_prefetchIfNeeded_successUpdatesEntriesPersistsCacheAndSkipsImmediateForcedRefetch() async { let csv = """ relay url,lat,lon refreshed.example,12,34 """ let harness = makeHarness(fetchCSV: csv) let directory = GeoRelayDirectory(dependencies: harness.dependencies) directory.prefetchIfNeeded() let refreshed = await waitUntil { directory.entries == [GeoRelayDirectory.Entry(host: "refreshed.example", lat: 12, lon: 34)] } XCTAssertTrue(refreshed) let requestCount = await harness.fetcher.recordedRequestCount() XCTAssertEqual(requestCount, 1) XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], csv.data(using: .utf8)) XCTAssertEqual(harness.userDefaults.object(forKey: "georelay.lastFetchAt") as? Date, harness.clock.now) XCTAssertEqual(directory.debugRetryAttempt, 0) XCTAssertFalse(directory.debugHasRetryTask) directory.prefetchIfNeeded(force: true) try? await Task.sleep(nanoseconds: 20_000_000) let forcedRequestCount = await harness.fetcher.recordedRequestCount() XCTAssertEqual(forcedRequestCount, 1) } func test_prefetchIfNeeded_runsRemoteFetchOffMainThread() async { var factoryThreadFlags: [Bool] = [] let threadRecorder = MainThreadRecorder() let harness = makeHarness( fetchCSV: """ relay url,lat,lon background.example,8,9 """, fetchFactoryObserver: { factoryThreadFlags.append(isExecutingOnMainThread()) }, fetchObserver: { await threadRecorder.record(isExecutingOnMainThread()) } ) let directory = GeoRelayDirectory(dependencies: harness.dependencies) directory.prefetchIfNeeded() let refreshed = await waitUntil { directory.entries == [GeoRelayDirectory.Entry(host: "background.example", lat: 8, lon: 9)] } XCTAssertTrue(refreshed) XCTAssertEqual(factoryThreadFlags, [true]) let recordedValues = await threadRecorder.recordedValues() XCTAssertEqual(recordedValues, [false]) } func test_prefetchIfNeeded_failureSchedulesRetryAndRecoversOnNextFetch() async { let csv = """ relay url,lat,lon retry.example,5,6 """ let harness = makeHarness( fetchResults: [ .failure(GeoRelayTestError.network), .success(csv.data(using: .utf8)!) ] ) let directory = GeoRelayDirectory(dependencies: harness.dependencies) directory.prefetchIfNeeded() let recovered = await waitUntil { directory.entries == [GeoRelayDirectory.Entry(host: "retry.example", lat: 5, lon: 6)] } XCTAssertTrue(recovered) let requestCount = await harness.fetcher.recordedRequestCount() let retryDelays = await harness.retryRecorder.recordedDelays() XCTAssertEqual(requestCount, 2) XCTAssertEqual(retryDelays, [5]) XCTAssertEqual(directory.debugRetryAttempt, 0) XCTAssertFalse(directory.debugHasRetryTask) } func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async { let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive") let harness = makeHarness( fetchCSV: """ relay url,lat,lon observer.example,1,2 """, autoStart: true, activeNotificationName: activeNotification ) var directory: GeoRelayDirectory? = GeoRelayDirectory(dependencies: harness.dependencies) let initialFetch = await waitUntil { await harness.fetcher.recordedRequestCount() == 1 } XCTAssertTrue(initialFetch) XCTAssertEqual(directory?.debugObserverCount, 2) harness.clock.now = harness.clock.now.addingTimeInterval(6) harness.notificationCenter.post(name: .TorDidBecomeReady, object: nil) let torTriggered = await waitUntil { await harness.fetcher.recordedRequestCount() == 2 } XCTAssertTrue(torTriggered) harness.clock.now = harness.clock.now.addingTimeInterval(61) harness.notificationCenter.post(name: activeNotification, object: nil) let activeTriggered = await waitUntil { await harness.fetcher.recordedRequestCount() == 3 } XCTAssertTrue(activeTriggered) weak var weakDirectory: GeoRelayDirectory? weakDirectory = directory directory = nil XCTAssertNil(weakDirectory) } private func makeHarness( cacheCSV: String? = nil, bundleCSV: String? = nil, workingDirectoryCSV: String? = nil, fetchCSV: String? = nil, fetchResults: [Result] = [], fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil, fetchObserver: (@Sendable () async -> Void)? = nil, autoStart: Bool = false, activeNotificationName: Notification.Name? = nil ) -> GeoRelayHarness { let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)" let userDefaults = UserDefaults(suiteName: userDefaultsSuite)! userDefaults.removePersistentDomain(forName: userDefaultsSuite) let notificationCenter = NotificationCenter() let clock = MutableGeoClock(now: Date(timeIntervalSince1970: 1_700_000_000)) let fileStore = InMemoryFileStore() let cacheURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-cache.csv") let bundleURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-bundle.csv") let cwd = "/tmp/\(UUID().uuidString)-cwd" let cwdURL = URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv") if let cacheCSV { fileStore.dataByURL[cacheURL] = Data(cacheCSV.utf8) } if let bundleCSV { fileStore.dataByURL[bundleURL] = Data(bundleCSV.utf8) } if let workingDirectoryCSV { fileStore.dataByURL[cwdURL] = Data(workingDirectoryCSV.utf8) } let defaultFetchData = Data((fetchCSV ?? bundleCSV ?? cacheCSV ?? "relay url,lat,lon\nfallback.example,0,0\n").utf8) let fetcher = FetchProbe(responses: fetchResults, defaultData: defaultFetchData) let retryRecorder = RetryDelayRecorder() let dependencies = GeoRelayDirectoryDependencies( userDefaults: userDefaults, notificationCenter: notificationCenter, now: { clock.now }, remoteURL: URL(string: "https://example.com/nostr_relays.csv")!, fetchInterval: 60, refreshCheckInterval: 0, retryInitialSeconds: 5, retryMaxSeconds: 40, awaitTorReady: { true }, makeFetchData: { fetchFactoryObserver?() return { request in await fetchObserver?() return try await fetcher.fetch(request) } }, readData: { url in fileStore.dataByURL[url] }, writeData: { data, url in fileStore.dataByURL[url] = data }, cacheURL: { cacheURL }, bundledCSVURLs: bundleCSV == nil ? { [] } : { [bundleURL] }, currentDirectoryPath: workingDirectoryCSV == nil ? { nil } : { cwd }, retrySleep: { delay in await retryRecorder.record(delay) }, activeNotificationName: activeNotificationName, autoStart: autoStart ) return GeoRelayHarness( dependencies: dependencies, clock: clock, fileStore: fileStore, fetcher: fetcher, retryRecorder: retryRecorder, userDefaults: userDefaults, notificationCenter: notificationCenter, cacheURL: cacheURL ) } private func waitUntil( timeout: TimeInterval = 1.0, condition: @escaping @MainActor () async -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if await condition() { return true } try? await Task.sleep(nanoseconds: 10_000_000) } return await condition() } } private struct GeoRelayHarness { let dependencies: GeoRelayDirectoryDependencies let clock: MutableGeoClock let fileStore: InMemoryFileStore let fetcher: FetchProbe let retryRecorder: RetryDelayRecorder let userDefaults: UserDefaults let notificationCenter: NotificationCenter let cacheURL: URL } private final class MutableGeoClock { var now: Date init(now: Date) { self.now = now } } private final class InMemoryFileStore { var dataByURL: [URL: Data] = [:] } private actor FetchProbe { private var responses: [Result] private let defaultData: Data private(set) var requestCount = 0 init(responses: [Result], defaultData: Data) { self.responses = responses self.defaultData = defaultData } func fetch(_ request: URLRequest) async throws -> Data { _ = request requestCount += 1 if !responses.isEmpty { return try responses.removeFirst().get() } return defaultData } func recordedRequestCount() -> Int { requestCount } } private actor RetryDelayRecorder { private(set) var delays: [TimeInterval] = [] func record(_ delay: TimeInterval) { delays.append(delay) } func recordedDelays() -> [TimeInterval] { delays } } private actor MainThreadRecorder { private var values: [Bool] = [] func record(_ value: Bool) { values.append(value) } func recordedValues() -> [Bool] { values } } private enum GeoRelayTestError: Error { case network } private func isExecutingOnMainThread() -> Bool { Thread.isMainThread } ================================================ FILE: bitchatTests/NostrProtocolTests.swift ================================================ // // NostrProtocolTests.swift // bitchatTests // // Tests for NIP-17 gift-wrapped private messages // import Testing import CryptoKit import Foundation @testable import bitchat struct NostrProtocolTests { @Test func nip17MessageRoundTrip() throws { // Create sender and recipient identities let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() print("Sender pubkey: \(sender.publicKeyHex)") print("Recipient pubkey: \(recipient.publicKeyHex)") // Create a test message let originalContent = "Hello from NIP-17 test!" // Create encrypted gift wrap let giftWrap = try NostrProtocol.createPrivateMessage( content: originalContent, recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) print("Gift wrap created with ID: \(giftWrap.id)") print("Gift wrap pubkey: \(giftWrap.pubkey)") // Decrypt the gift wrap let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage( giftWrap: giftWrap, recipientIdentity: recipient ) // Verify #expect(decryptedContent == originalContent) #expect(senderPubkey == sender.publicKeyHex) // Verify timestamp is reasonable (within last minute) let messageDate = Date(timeIntervalSince1970: TimeInterval(timestamp)) let timeDiff = abs(messageDate.timeIntervalSinceNow) #expect(timeDiff < 60, "Message timestamp should be recent") print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)") } @Test func giftWrapUsesUniqueEphemeralKeys() throws { // Create identities let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() // Create two messages let message1 = try NostrProtocol.createPrivateMessage( content: "Message 1", recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) let message2 = try NostrProtocol.createPrivateMessage( content: "Message 2", recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) // Gift wrap pubkeys should be different (unique ephemeral keys) #expect(message1.pubkey != message2.pubkey) print("Message 1 gift wrap pubkey: \(message1.pubkey)") print("Message 2 gift wrap pubkey: \(message2.pubkey)") // Both should decrypt successfully let (content1, _, _) = try NostrProtocol.decryptPrivateMessage( giftWrap: message1, recipientIdentity: recipient ) let (content2, _, _) = try NostrProtocol.decryptPrivateMessage( giftWrap: message2, recipientIdentity: recipient ) #expect(content1 == "Message 1") #expect(content2 == "Message 2") } @Test func decryptionFailsWithWrongRecipient() throws { let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let wrongRecipient = try NostrIdentity.generate() // Create message for recipient let giftWrap = try NostrProtocol.createPrivateMessage( content: "Secret message", recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) // Try to decrypt with wrong recipient if #available(macOS 14.4, iOS 17.4, *) { #expect(throws: CryptoKitError.authenticationFailure) { try NostrProtocol.decryptPrivateMessage( giftWrap: giftWrap, recipientIdentity: wrongRecipient ) } } else { #expect(throws: (any Error).self) { try NostrProtocol.decryptPrivateMessage( giftWrap: giftWrap, recipientIdentity: wrongRecipient ) } } } func testAckRoundTripNIP44V2_Delivered() throws { // Identities let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() // Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID) let messageID = "TEST-MSG-DELIVERED-1" let senderPeerID = PeerID(str: "0123456789abcdef") // 8-byte hex peer ID let embedded = try #require( NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID), "Failed to embed delivered ack" ) // Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally) let giftWrap = try NostrProtocol.createPrivateMessage( content: embedded, recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) // Ensure v2 format was used for ciphertext #expect(giftWrap.content.hasPrefix("v2:")) // Decrypt as recipient let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( giftWrap: giftWrap, recipientIdentity: recipient ) // Verify sender is correct #expect(senderPubkey == sender.publicKeyHex) // Parse BitChat payload #expect(content.hasPrefix("bitchat1:")) let base64url = String(content.dropFirst("bitchat1:".count)) let packetData = try #require(Self.base64URLDecode(base64url)) let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet") #expect(packet.type == MessageType.noiseEncrypted.rawValue) let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload") switch payload.type { case .delivered: let mid = String(data: payload.data, encoding: .utf8) #expect(mid == messageID) default: Issue.record("Unexpected payload type: \(payload.type)") } } @Test func ackRoundTripNIP44V2_ReadReceipt() throws { // Identities let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let messageID = "TEST-MSG-READ-1" let senderPeerID = PeerID(str: "fedcba9876543210") // 8-byte hex peer ID let embedded = try #require( NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID), "Failed to embed read ack" ) let giftWrap = try NostrProtocol.createPrivateMessage( content: embedded, recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) #expect(giftWrap.content.hasPrefix("v2:")) let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( giftWrap: giftWrap, recipientIdentity: recipient ) #expect(senderPubkey == sender.publicKeyHex) #expect(content.hasPrefix("bitchat1:")) let base64url = String(content.dropFirst("bitchat1:".count)) let packetData = try #require(Self.base64URLDecode(base64url)) let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet") #expect(packet.type == MessageType.noiseEncrypted.rawValue) let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload") switch payload.type { case .readReceipt: let mid = String(data: payload.data, encoding: .utf8) #expect(mid == messageID) default: Issue.record("Unexpected payload type: \(payload.type)") } } @Test func nostrEventSignatureVerification_roundTrip() throws { let identity = try NostrIdentity.generate() let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, tags: [], content: "Signed event" ) let signed = try event.sign(with: identity.schnorrSigningKey()) #expect(signed.isValidSignature()) } @Test func nostrEventSignatureVerification_detectsTamper() throws { let identity = try NostrIdentity.generate() let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .ephemeralEvent, tags: [], content: "Original" ) var signed = try event.sign(with: identity.schnorrSigningKey()) signed.id = "deadbeef" #expect(!signed.isValidSignature()) } @Test func geohashNotesSingleFilter_encodesExpectedTagShape() throws { let since = Date(timeIntervalSince1970: 1_234_567) let filter = NostrFilter.geohashNotes("u4pruyd", since: since, limit: 42) let data = try JSONEncoder().encode(filter) let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) #expect(object["kinds"] as? [Int] == [1]) #expect(object["#g"] as? [String] == ["u4pruyd"]) #expect(object["since"] as? Int == 1_234_567) #expect(object["limit"] as? Int == 42) } // MARK: - Helpers private static func base64URLDecode(_ s: String) -> Data? { var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") let rem = str.count % 4 if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) } return Data(base64Encoded: str) } } ================================================ FILE: bitchatTests/NotificationBlockingTests.swift ================================================ // // NotificationBlockingTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // // BCH-01-012: Tests for notification blocking feature import Testing import Foundation @testable import bitchat struct NotificationBlockingTests { // MARK: - Nostr Blocking Tests @Test("isNostrBlocked returns true for blocked pubkeys") func isNostrBlocked_returnsTrueForBlockedPubkey() { let keychain = MockKeychain() let manager = MockIdentityManager(keychain) let testPubkey = "abc123def456".lowercased() // Initially not blocked #expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == false) // Block the pubkey manager.setNostrBlocked(testPubkey, isBlocked: true) // Now should be blocked #expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == true) // Unblock manager.setNostrBlocked(testPubkey, isBlocked: false) #expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == false) } @Test("isBlocked returns true for blocked fingerprints") func isBlocked_returnsTrueForBlockedFingerprint() { let keychain = MockKeychain() let manager = MockIdentityManager(keychain) let testFingerprint = "fingerprint123" // Initially not blocked #expect(manager.isBlocked(fingerprint: testFingerprint) == false) // Block the fingerprint manager.setBlocked(testFingerprint, isBlocked: true) // Now should be blocked #expect(manager.isBlocked(fingerprint: testFingerprint) == true) // Unblock manager.setBlocked(testFingerprint, isBlocked: false) #expect(manager.isBlocked(fingerprint: testFingerprint) == false) } @Test("getBlockedNostrPubkeys returns all blocked pubkeys") func getBlockedNostrPubkeys_returnsAllBlocked() { let keychain = MockKeychain() let manager = MockIdentityManager(keychain) let pubkey1 = "pubkey1".lowercased() let pubkey2 = "pubkey2".lowercased() let pubkey3 = "pubkey3".lowercased() manager.setNostrBlocked(pubkey1, isBlocked: true) manager.setNostrBlocked(pubkey2, isBlocked: true) manager.setNostrBlocked(pubkey3, isBlocked: true) let blocked = manager.getBlockedNostrPubkeys() #expect(blocked.count == 3) #expect(blocked.contains(pubkey1)) #expect(blocked.contains(pubkey2)) #expect(blocked.contains(pubkey3)) } // MARK: - Message Blocking Tests @Test("BitchatMessage with blocked sender is identified") func bitchatMessage_blockedSenderIdentified() { let keychain = MockKeychain() let manager = MockIdentityManager(keychain) let blockedFingerprint = "blocked_fingerprint_123" manager.setBlocked(blockedFingerprint, isBlocked: true) #expect(manager.isBlocked(fingerprint: blockedFingerprint) == true) } @Test("Case insensitive blocking for Nostr pubkeys") func nostrBlocking_caseInsensitive() { let keychain = MockKeychain() let manager = MockIdentityManager(keychain) let pubkeyLower = "abc123def456" // Block lowercase manager.setNostrBlocked(pubkeyLower, isBlocked: true) // Check lowercase is blocked #expect(manager.isNostrBlocked(pubkeyHexLowercased: pubkeyLower) == true) // Note: The API expects lowercased input, so callers must normalize // This test verifies the contract that pubkeys should be lowercased before checking // The fix in ChatViewModel+Nostr.swift normalizes via event.pubkey.lowercased() } } ================================================ FILE: bitchatTests/NotificationStreamAssemblerTests.swift ================================================ import Testing import Foundation @testable import bitchat struct NotificationStreamAssemblerTests { private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket { let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]) return BitchatPacket( type: MessageType.message.rawValue, senderID: sender, recipientID: nil, timestamp: timestamp, payload: Data([0xDE, 0xAD, 0xBE, 0xEF]), signature: nil, ttl: 3 ) } @Test func assemblesSingleFrameAcrossChunks() throws { var assembler = NotificationStreamAssembler() let packet = makePacket() let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet") #expect(BinaryProtocol.decode(frame) != nil) let payloadLen = (Int(frame[12]) << 8) | Int(frame[13]) #expect(payloadLen == packet.payload.count) let splitIndex = min(20, max(1, frame.count / 2)) let first = frame.prefix(splitIndex) let second = frame.suffix(from: splitIndex) #expect(first.count + second.count == frame.count) var result = assembler.append(first) #expect(result.frames.isEmpty) #expect(result.droppedPrefixes.isEmpty) #expect(!result.reset) result = assembler.append(second) #expect(result.frames.count == 1) #expect(result.droppedPrefixes.isEmpty) #expect(!result.reset) let frameData = try #require(result.frames.first, "Missing frame data") #expect(frameData.count == frame.count) let decoded = try #require(BinaryProtocol.decode(frameData), "Failed to decode frame") #expect(decoded.type == packet.type) #expect(decoded.payload == packet.payload) #expect(decoded.senderID == packet.senderID) #expect(decoded.timestamp == packet.timestamp) var directAssembler = NotificationStreamAssembler() let directResult = directAssembler.append(frame) #expect(directResult.frames.first?.count == frame.count) } @Test func assemblesMultipleFramesSequentially() throws { var assembler = NotificationStreamAssembler() let packet1 = makePacket(timestamp: 0xABC) let packet2 = makePacket(timestamp: 0xDEF) let frame1 = try #require(packet1.toBinaryData(padding: false), "Failed to encode packet") let frame2 = try #require(packet2.toBinaryData(padding: false), "Failed to encode packet") var combined = Data() combined.append(frame1) combined.append(frame2) let firstChunk = combined.prefix(20) let secondChunk = combined.suffix(from: 20) var result = assembler.append(firstChunk) #expect(result.frames.isEmpty) result = assembler.append(secondChunk) #expect(result.frames.count == 2) let decoded1 = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame") let decoded2 = try #require(BinaryProtocol.decode(result.frames[1]), "Failed to decode frame") #expect(decoded1.timestamp == packet1.timestamp) #expect(decoded2.timestamp == packet2.timestamp) } @Test func dropsInvalidPrefixByte() throws { var assembler = NotificationStreamAssembler() let packet = makePacket(timestamp: 0xF00) let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet") var noisyFrame = Data([0x00]) noisyFrame.append(frame) let result = assembler.append(noisyFrame) #expect(result.droppedPrefixes == [0x00]) #expect(result.frames.count == 1) #expect(result.reset == false) let decoded = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame after drop") #expect(decoded.timestamp == packet.timestamp) } func testAssemblesCompressedLargeFrame() throws { var assembler = NotificationStreamAssembler() // Keep the fixture below FileTransferLimits.maxPayloadBytes so encoding succeeds while still exercising compression. let largeContent = Data(repeating: 0x41, count: 600_000) let filePacket = BitchatFilePacket( fileName: "large.bin", fileSize: UInt64(largeContent.count), mimeType: "application/octet-stream", content: largeContent ) let tlvPayload = try #require(filePacket.encode(), "Failed to encode file packet") let senderID = Data(repeating: 0xAA, count: BinaryProtocol.senderIDSize) let packet = BitchatPacket( type: MessageType.fileTransfer.rawValue, senderID: senderID, recipientID: nil, timestamp: 0x010203040506, payload: tlvPayload, signature: nil, ttl: 3, version: 2 ) let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet frame") #expect(BinaryProtocol.Offsets.flags < frame.count) let flags = frame[frame.startIndex + BinaryProtocol.Offsets.flags] #expect((flags & BinaryProtocol.Flags.isCompressed) != 0, "Frame should be compressed for large payloads") let splitIndex = min(4096, frame.count / 2) var result = assembler.append(frame.prefix(splitIndex)) #expect(result.frames.isEmpty) result = assembler.append(frame.suffix(from: splitIndex)) #expect(result.frames.count == 1) #expect(result.droppedPrefixes.isEmpty) #expect(result.reset == false) let assembled = try #require(result.frames.first, "Missing assembled frame") #expect(assembled.count == frame.count) let decodedPacket = try #require(BinaryProtocol.decode(assembled), "Failed to decode compressed frame") #expect(decodedPacket.payload.count == tlvPayload.count) let decodedFile = try #require(BitchatFilePacket.decode(decodedPacket.payload), "Failed to decode TLV payload") #expect(decodedFile.fileName == filePacket.fileName) #expect(decodedFile.mimeType == filePacket.mimeType) #expect(decodedFile.content.count == largeContent.count) #expect(decodedFile.content.prefix(32) == largeContent.prefix(32)) } } ================================================ FILE: bitchatTests/PreviewKeychainManagerTests.swift ================================================ import Foundation import Testing @testable import bitchat @Suite("PreviewKeychainManager Tests") struct PreviewKeychainManagerTests { @Test("Preview keychain manager stores identity and service-scoped data in memory") func previewKeychainManagerRoundTripsData() { let manager = PreviewKeychainManager() let identityKey = Data([1, 2, 3, 4]) let serviceKey = "preview-service" let scopedData = Data([9, 8, 7, 6]) #expect(!manager.verifyIdentityKeyExists()) #expect(manager.saveIdentityKey(identityKey, forKey: "noiseStaticKey")) #expect(manager.getIdentityKey(forKey: "noiseStaticKey") == identityKey) #expect(manager.saveIdentityKey(identityKey, forKey: "identity_noiseStaticKey")) #expect(manager.verifyIdentityKeyExists()) if case .success(let stored) = manager.getIdentityKeyWithResult(forKey: "noiseStaticKey") { #expect(stored == identityKey) } else { Issue.record("Expected stored preview identity key") } if case .success = manager.saveIdentityKeyWithResult(Data([5, 6, 7]), forKey: "ed25519SigningKey") { } else { Issue.record("Expected preview keychain save to succeed") } manager.save(key: "blob", data: scopedData, service: serviceKey, accessible: nil) #expect(manager.load(key: "blob", service: serviceKey) == scopedData) manager.delete(key: "blob", service: serviceKey) #expect(manager.load(key: "blob", service: serviceKey) == nil) var secretData = Data([4, 3, 2, 1]) var secretString = "secret" manager.secureClear(&secretData) manager.secureClear(&secretString) #expect(secretData == Data([4, 3, 2, 1])) #expect(secretString == "secret") #expect(manager.deleteIdentityKey(forKey: "noiseStaticKey")) #expect(manager.deleteIdentityKey(forKey: "identity_noiseStaticKey")) #expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil) #expect(manager.deleteAllKeychainData()) if case .itemNotFound = manager.getIdentityKeyWithResult(forKey: "ed25519SigningKey") { } else { Issue.record("Expected preview keychain to be empty after deleteAllKeychainData") } } } ================================================ FILE: bitchatTests/Protocol/BinaryProtocolPaddingTests.swift ================================================ // // BinaryProtocolPaddingTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // import Testing @testable import bitchat struct BinaryProtocolPaddingTests { @Test func padded_vs_unpadded_length() throws { // Use helper to create a small test packet let packet = TestHelpers.createTestPacket() let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded") let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded") #expect(padded.count >= unpadded.count, "Padded frame should be >= unpadded") } @Test func decode_padded_and_unpadded_round_trip() throws { let packet = TestHelpers.createTestPacket() let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded") let dec1 = try #require(BinaryProtocol.decode(padded), "decode padded") #expect(dec1.type == packet.type) #expect(dec1.payload == packet.payload) let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded") let dec2 = try #require(BinaryProtocol.decode(unpadded), "decode unpadded") #expect(dec2.type == packet.type) #expect(dec2.payload == packet.payload) } } ================================================ FILE: bitchatTests/Protocol/BinaryProtocolTests.swift ================================================ // // BinaryProtocolTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Testing import Foundation @testable import bitchat struct BinaryProtocolTests { // MARK: - Basic Encoding/Decoding Tests @Test func basicPacketEncodingDecoding() throws { let originalPacket = TestHelpers.createTestPacket() let encodedData = try #require(BinaryProtocol.encode(originalPacket), "Failed to encode packet") let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet") // Verify #expect(decodedPacket.type == originalPacket.type) #expect(decodedPacket.ttl == originalPacket.ttl) #expect(decodedPacket.timestamp == originalPacket.timestamp) #expect(decodedPacket.payload == originalPacket.payload) // Sender ID should match (accounting for padding) let originalSenderID = originalPacket.senderID.prefix(BinaryProtocol.senderIDSize) let decodedSenderID = decodedPacket.senderID.trimmingNullBytes() #expect(decodedSenderID == originalSenderID) } @Test func trimmingNullBytesReturnsOriginalDataWhenNoNullsPresent() { let raw = Data([0x41, 0x42, 0x43]) #expect(raw.trimmingNullBytes() == raw) } @Test func packetWithRecipient() throws { let recipientID = PeerID(str: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789") let packet = TestHelpers.createTestPacket(recipientID: recipientID) let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with recipient") let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with recipient") // Verify recipient #expect(decodedPacket.recipientID != nil) let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes() // TODO: Check if this is intended that the decoding only gets the first 8 #expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01") } @Test func packetWithSignature() throws { let packet = TestHelpers.createTestPacket(signature: TestConstants.testSignature) let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with signature") let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with signature") // Verify signature #expect(decodedPacket.signature != nil) #expect(decodedPacket.signature == TestConstants.testSignature) } // MARK: - Source-Based Routing Tests (v2 only) @Test func packetWithRouteRoundTrip() throws { let route: [Data] = [ try #require(Data(hexString: "0102030405060708")), try #require(Data(hexString: "1112131415161718")), try #require(Data(hexString: "2122232425262728")) ] // Route is only supported for v2+ packets var packet = BitchatPacket( type: 0x01, senderID: route[0], recipientID: route.last, timestamp: 1_720_000_000_000, payload: Data("route-test".utf8), signature: nil, ttl: 6, version: 2 ) packet.route = route let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route") let flagsByte = encoded[BinaryProtocol.Offsets.flags] #expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0) let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route") #expect(decoded.version == 2) let decodedRoute = try #require(decoded.route) #expect(decodedRoute.count == route.count) for (expected, actual) in zip(route, decodedRoute) { #expect(actual == expected) } } @Test func packetWithRoutePadsShortHop() throws { let sender = try #require(Data(hexString: "0011223344556677")) let destination = try #require(Data(hexString: "8899aabbccddeeff")) let shortHop = Data([0xAA, 0xBB, 0xCC]) // Route is only supported for v2+ packets var packet = BitchatPacket( type: 0x02, senderID: sender, recipientID: destination, timestamp: 1_730_000_000_000, payload: Data("pad-test".utf8), signature: nil, ttl: 5, version: 2 ) packet.route = [shortHop, destination] let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with short hop route") let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with short hop route") let decodedRoute = try #require(decoded.route) let firstHop = try #require(decodedRoute.first) #expect(firstHop.count == BinaryProtocol.senderIDSize) #expect(firstHop.prefix(shortHop.count) == shortHop) let paddingBytes = firstHop.suffix(firstHop.count - shortHop.count) #expect(paddingBytes.allSatisfy { $0 == 0 }) } @Test func packetWithRouteAndCompressedPayload() throws { let route: [Data] = [ try #require(Data(hexString: "0101010101010101")), try #require(Data(hexString: "0202020202020202")) ] let repeatedString = String(repeating: "compress-me", count: 150) // Route is only supported for v2+ packets var packet = BitchatPacket( type: 0x03, senderID: route[0], recipientID: route.last, timestamp: 1_740_000_000_000, payload: Data(repeatedString.utf8), signature: nil, ttl: 7, version: 2 ) packet.route = route let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route and compression") let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route and compression") #expect(decoded.payload == Data(repeatedString.utf8)) let decodedRoute = try #require(decoded.route) #expect(decodedRoute == route) } @Test func v1PacketIgnoresRouteOnEncode() throws { // v1 packets should NOT include route even if route is set on the packet object let route: [Data] = [ try #require(Data(hexString: "0102030405060708")), try #require(Data(hexString: "1112131415161718")) ] var packet = BitchatPacket( type: 0x01, senderID: route[0], recipientID: route.last, timestamp: 1_720_000_000_000, payload: Data("v1-no-route".utf8), signature: nil, ttl: 6 // version defaults to 1 (v1 packet) ) packet.route = route // route is set but should be ignored for v1 let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v1 packet") // HAS_ROUTE flag should NOT be set for v1 packets let flagsByte = encoded[BinaryProtocol.Offsets.flags] #expect((flagsByte & BinaryProtocol.Flags.hasRoute) == 0, "v1 packet should not have HAS_ROUTE flag set") // Decoded packet should have no route let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v1 packet") #expect(decoded.version == 1) #expect(decoded.route == nil, "v1 packet should decode with nil route") #expect(decoded.payload == Data("v1-no-route".utf8)) } @Test func v2PacketIncludesRouteOnEncode() throws { // v2 packets SHOULD include route when route is set let route: [Data] = [ try #require(Data(hexString: "0102030405060708")), try #require(Data(hexString: "1112131415161718")) ] var packet = BitchatPacket( type: 0x01, senderID: route[0], recipientID: route.last, timestamp: 1_720_000_000_000, payload: Data("v2-with-route".utf8), signature: nil, ttl: 6, version: 2 ) packet.route = route let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v2 packet") // HAS_ROUTE flag SHOULD be set for v2 packets with route let flagsByte = encoded[BinaryProtocol.Offsets.flags] #expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0, "v2 packet should have HAS_ROUTE flag set") // Decoded packet should have route let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v2 packet") #expect(decoded.version == 2) let decodedRoute = try #require(decoded.route, "v2 packet should decode with route") #expect(decodedRoute.count == route.count) #expect(decoded.payload == Data("v2-with-route".utf8)) } @Test func v2PacketWithoutRouteDecodesCorrectly() throws { // v2 packet without route should still work let sender = try #require(Data(hexString: "0011223344556677")) let recipient = try #require(Data(hexString: "8899aabbccddeeff")) let packet = BitchatPacket( type: 0x02, senderID: sender, recipientID: recipient, timestamp: 1_750_000_000_000, payload: Data("v2-no-route".utf8), signature: nil, ttl: 5, version: 2 ) // route is nil by default let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v2 packet without route") // HAS_ROUTE flag should NOT be set when no route let flagsByte = encoded[BinaryProtocol.Offsets.flags] #expect((flagsByte & BinaryProtocol.Flags.hasRoute) == 0, "v2 packet without route should not have HAS_ROUTE flag") let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v2 packet without route") #expect(decoded.version == 2) #expect(decoded.route == nil) #expect(decoded.payload == Data("v2-no-route".utf8)) } @Test func v1AndV2PayloadLengthDifference() throws { // Verify that payloadLength does NOT include route bytes // by comparing encoded sizes let route: [Data] = [ try #require(Data(hexString: "0102030405060708")) ] let payloadData = Data("test-payload".utf8) // v1 packet (route ignored) var v1Packet = BitchatPacket( type: 0x01, senderID: route[0], recipientID: nil, timestamp: 1_720_000_000_000, payload: payloadData, signature: nil, ttl: 6 // version defaults to 1 ) v1Packet.route = route // will be ignored for v1 // v2 packet with same payload but route included var v2Packet = BitchatPacket( type: 0x01, senderID: route[0], recipientID: nil, timestamp: 1_720_000_000_000, payload: payloadData, signature: nil, ttl: 6, version: 2 ) v2Packet.route = route let v1Encoded = try #require(BinaryProtocol.encode(v1Packet, padding: false)) let v2Encoded = try #require(BinaryProtocol.encode(v2Packet, padding: false)) // v2 should be larger by: 2 bytes (header length field difference) + 1 byte (route count) + 8 bytes (one hop) // Header: v1=14, v2=16 -> +2 bytes // Route: 1 + 8 = 9 bytes // Total expected difference: 11 bytes let expectedDiff = 2 + 1 + 8 // header diff + route count + one hop #expect(v2Encoded.count - v1Encoded.count == expectedDiff, "v2 packet should be \(expectedDiff) bytes larger than v1 (actual diff: \(v2Encoded.count - v1Encoded.count))") } // MARK: - Compression Tests @Test("Create a large, compressible payload above current threshold (2048B)") func payloadCompression() throws { let repeatedString = String(repeating: "This is a test message. ", count: 200) let largePayload = repeatedString.data(using: .utf8)! let packet = TestHelpers.createTestPacket(payload: largePayload) // Encode (should compress) let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload") // The encoded size should be smaller than uncompressed due to compression let headerSize = try #require(BinaryProtocol.headerSize(for: packet.version), "Invalid packet version") let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count #expect(encodedData.count < uncompressedSize, "Compressed packet should be smaller than uncompressed form") // Decode and verify let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet") #expect(decodedPacket.payload == largePayload) } @Test("Small payloads should not be compressed") func smallPayloadNoCompression() throws { let smallPayload = "Hi".data(using: .utf8)! let packet = TestHelpers.createTestPacket(payload: smallPayload) let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet") let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet") #expect(decodedPacket.payload == smallPayload) } @Test("Reject payloads larger than the framed file cap") func oversizedPayloadIsRejected() throws { let targetSize = FileTransferLimits.maxFramedFileBytes + 1 var oversized = Data() oversized.reserveCapacity(targetSize) let byteRun = Data((0...255).map { UInt8($0) }) while oversized.count < targetSize { let remaining = targetSize - oversized.count if remaining >= byteRun.count { oversized.append(byteRun) } else { oversized.append(byteRun.prefix(remaining)) } } let packet = BitchatPacket( type: MessageType.message.rawValue, senderID: Data(hexString: "0011223344556677") ?? Data(), recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: oversized, signature: nil, ttl: 1, version: 2 ) let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode oversized packet") #expect(BinaryProtocol.decode(encoded) == nil) } // MARK: - Message Padding Tests @Test func messagePadding() throws { let payloads = [ "Short", String(repeating: "Medium length message content ", count: 10), // ~300 bytes String(repeating: "Long message content that should exceed the 512 byte limit ", count: 20), // ~1200+ bytes String(repeating: "Very long message content that should definitely exceed the 2048 byte limit for sure ", count: 30) // ~2700+ bytes ] var encodedSizes = Set() for payload in payloads { let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!) let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet") // Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently) let blockSizes = [256, 512, 1024, 2048] if encodedData.count <= 2048 { #expect(blockSizes.contains(encodedData.count), "Encoded size \(encodedData.count) is not a standard block size") } else { // For very large payloads we expect no additional padding beyond raw size #expect(encodedData.count > 2048) } encodedSizes.insert(encodedData.count) // Verify decoding works let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode padded packet") #expect(String(data: decodedPacket.payload, encoding: .utf8) == payload) } // Different payload sizes (within <=2048) may map to the same bucket depending on compression. // Require at least one padded size to be present. #expect(encodedSizes.filter { $0 <= 2048 }.count >= 1, "Expected at least one padded size up to 2048, got \(encodedSizes)") } @Test func invalidPKCS7PaddingIsRejected() throws { let pkt = TestHelpers.createTestPacket(payload: Data(repeating: 0x41, count: 50)) // small let enc0 = try #require(BinaryProtocol.encode(pkt), "encode failed") // Force padding to known block for test stability var enc = MessagePadding.pad(enc0, toSize: 256) let unpadded = MessagePadding.unpad(enc) let padLen = enc.count - unpadded.count if padLen > 0 { // Set last pad byte to wrong value (padLen-1) to break PKCS#7 enc[enc.count - 1] = UInt8((padLen - 1) & 0xFF) let maybe = BinaryProtocol.decode(enc) // If decode still succeeds (nested pad edge case), at least ensure payload integrity if let pkt2 = maybe { #expect(pkt2.payload == pkt.payload) } else { #expect(maybe == nil) } } else { // If no padding was applied, just assert decode succeeds (nothing to test) #expect(BinaryProtocol.decode(enc) != nil) } } // MARK: - Message Encoding/Decoding Tests @Test func messageEncodingDecoding() throws { let message = TestHelpers.createTestMessage() let payload = try #require(message.toBinaryPayload(), "Failed to encode message to binary") let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message from binary") #expect(decodedMessage.content == message.content) #expect(decodedMessage.sender == message.sender) #expect(decodedMessage.senderPeerID == message.senderPeerID) #expect(decodedMessage.isPrivate == message.isPrivate) // Timestamp should be close (within 1 second due to conversion) let timeDiff = abs(decodedMessage.timestamp.timeIntervalSince(message.timestamp)) #expect(timeDiff < 1) } func testPrivateMessageEncoding() throws { let message = TestHelpers.createTestMessage( isPrivate: true, recipientNickname: TestConstants.testNickname2 ) let payload = try #require(message.toBinaryPayload(), "Failed to encode private message") let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode private message") #expect(decodedMessage.isPrivate) #expect(decodedMessage.recipientNickname == TestConstants.testNickname2) } @Test func messageWithMentions() throws { let mentions = [TestConstants.testNickname2, TestConstants.testNickname3] let message = TestHelpers.createTestMessage(mentions: mentions) let payload = try #require(message.toBinaryPayload(), "Failed to encode message with mentions") let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message with mentions") #expect(decodedMessage.mentions == mentions) } @Test func relayMessageEncoding() throws { let message = BitchatMessage( id: UUID().uuidString, sender: TestConstants.testNickname1, content: TestConstants.testMessage1, timestamp: Date(), isRelay: true, originalSender: TestConstants.testNickname3, isPrivate: false, recipientNickname: nil, mentions: nil ) let payload = try #require(message.toBinaryPayload(), "Failed to encode relay message") let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode relay message") #expect(decodedMessage.isRelay) #expect(decodedMessage.originalSender == TestConstants.testNickname3) } // MARK: - Edge Cases and Error Handling @Test("Too small data") func invalidDataDecoding() throws { let tooSmall = Data(repeating: 0, count: 5) #expect(BinaryProtocol.decode(tooSmall) == nil) // Random data let random = TestHelpers.generateRandomData(length: 100) #expect(BinaryProtocol.decode(random) == nil) // Corrupted header let packet = TestHelpers.createTestPacket() var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet") // Corrupt the version byte encoded[0] = 0xFF #expect(BinaryProtocol.decode(encoded) == nil) } @Test("Test maximum size handling") func largeMessageHandling() throws { let largeContent = String(repeating: "X", count: 65535) // Max uint16 let message = TestHelpers.createTestMessage(content: largeContent) let payload = try #require(message.toBinaryPayload(), "Failed to handle large message") let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle large message") #expect(decodedMessage.content == largeContent) } @Test("Test message with empty content") func emptyFieldsHandling() throws { let emptyMessage = TestHelpers.createTestMessage(content: "") let payload = try #require(emptyMessage.toBinaryPayload(), "Failed to handle empty message") let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle empty message") #expect(decodedMessage.content.isEmpty) } // MARK: - Protocol Version Tests @Test("Test with supported version (version is always 1 in init)") func protocolVersionHandling() throws { let packet = TestHelpers.createTestPacket() let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with version") let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with version") #expect(decoded.version == 1) } @Test("Create packet data with unsupported version") func unsupportedProtocolVersion() throws { let packet = TestHelpers.createTestPacket() var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet") // Manually change version byte to unsupported value encoded[0] = 99 // Unsupported version // Should fail to decode #expect(BinaryProtocol.decode(encoded) == nil) } // MARK: - Bounds Checking Tests (Crash Prevention) @Test("Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available") func malformedPacketWithInvalidPayloadLength() throws { var malformedData = Data() // Valid header (13 bytes) malformedData.append(1) // version malformedData.append(1) // type malformedData.append(10) // ttl // Timestamp (8 bytes) for _ in 0..<8 { malformedData.append(0) } malformedData.append(0) // flags (no recipient, no signature, not compressed) // Invalid payload length: 193 (0x00c1) but we'll only provide 8 bytes total data malformedData.append(0x00) // high byte malformedData.append(0xc1) // low byte (193) // SenderID (8 bytes) - this brings us to 21 bytes total for _ in 0..<8 { malformedData.append(0x01) } // Only provide 8 more bytes instead of the claimed 193 for _ in 0..<8 { malformedData.append(0x02) } // Total data is now 30 bytes, but payloadLength claims 193 #expect(malformedData.count == 30) // This should not crash - should return nil gracefully let result = BinaryProtocol.decode(malformedData) #expect(result == nil, "Malformed packet with invalid payload length should return nil, not crash") } @Test("Test various truncation scenarios") func truncatedPacketHandling() throws { let packet = TestHelpers.createTestPacket() let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet") // Test truncation at various points let truncationPoints = [0, 5, 10, 15, 20, 25] for point in truncationPoints { let truncated = validEncoded.prefix(point) let result = BinaryProtocol.decode(truncated) #expect(result == nil, "Truncated packet at \(point) bytes should return nil, not crash") } } @Test("Test compressed packet with invalid original size") func malformedCompressedPacket() throws { var malformedData = Data() // Valid header malformedData.append(1) // version malformedData.append(1) // type malformedData.append(10) // ttl // Timestamp (8 bytes) for _ in 0..<8 { malformedData.append(0) } malformedData.append(0x04) // flags: isCompressed = true // Small payload length that's insufficient for compression malformedData.append(0x00) // high byte malformedData.append(0x01) // low byte (1 byte - insufficient for 2-byte original size) // SenderID (8 bytes) for _ in 0..<8 { malformedData.append(0x01) } // Only 1 byte of "compressed" data (should need at least 2 for original size) malformedData.append(0x99) // Should handle this gracefully let result = BinaryProtocol.decode(malformedData) #expect(result == nil, "Malformed compressed packet should return nil, not crash") } @Test("Test packet claiming extremely large payload") func excessivelyLargePayloadLength() throws { var malformedData = Data() // Valid header malformedData.append(1) // version malformedData.append(1) // type malformedData.append(10) // ttl // Timestamp (8 bytes) for _ in 0..<8 { malformedData.append(0) } malformedData.append(0) // flags // Maximum payload length (65535) malformedData.append(0xFF) // high byte malformedData.append(0xFF) // low byte // SenderID (8 bytes) for _ in 0..<8 { malformedData.append(0x01) } // Provide only a tiny amount of actual data malformedData.append(contentsOf: [0x01, 0x02, 0x03]) // Should handle this gracefully without trying to allocate massive amounts of memory let result = BinaryProtocol.decode(malformedData) #expect(result == nil, "Packet with excessive payload length should return nil, not crash") } @Test("Test compressed packet with unreasonable original size") func compressedPacketWithInvalidOriginalSize() throws { var malformedData = Data() // Valid header malformedData.append(1) // version malformedData.append(1) // type malformedData.append(10) // ttl // Timestamp (8 bytes) for _ in 0..<8 { malformedData.append(0) } malformedData.append(0x04) // flags: isCompressed = true // Reasonable payload length malformedData.append(0x00) // high byte malformedData.append(0x10) // low byte (16 bytes) // SenderID (8 bytes) for _ in 0..<8 { malformedData.append(0x01) } // Original size claiming to be extremely large (2MB) malformedData.append(0x20) // high byte of original size malformedData.append(0x00) // low byte of original size (0x2000 = 8192, but let's make it larger with more bytes) // Add more bytes to make it claim larger size - but this will be invalid // because our validation should catch unreasonable sizes malformedData.append(contentsOf: [0x01, 0x02, 0x03, 0x04]) // Some compressed data // Pad to match payload length while malformedData.count < 21 + 16 { // header + senderID + payload malformedData.append(0x00) } let result = BinaryProtocol.decode(malformedData) #expect(result == nil, "Compressed packet with invalid original size should return nil, not crash") } @Test("Test compressed packet with suspicious compression ratio") func compressedPacketWithSuspiciousCompressionRatio() { var malformedData = Data() malformedData.append(1) // version malformedData.append(1) // type malformedData.append(10) // ttl for _ in 0..<8 { malformedData.append(0) } malformedData.append(0x04) // isCompressed malformedData.append(0x00) malformedData.append(0x03) // payloadLength = 3 (2 original-size bytes + 1 compressed byte) for _ in 0..<8 { malformedData.append(0x01) } malformedData.append(0xFF) malformedData.append(0xFF) // originalSize = 65535 malformedData.append(0x99) // compressed payload length = 1 => ratio > 50_000 #expect(BinaryProtocol.decode(malformedData) == nil) } @Test("Test packet designed to cause integer overflow") func maliciousPacketWithIntegerOverflow() throws { var maliciousData = Data() // Valid header maliciousData.append(1) // version maliciousData.append(1) // type maliciousData.append(10) // ttl // Timestamp (8 bytes) for _ in 0..<8 { maliciousData.append(0) } // Set flags to have recipient and signature (increase expected size) maliciousData.append(0x03) // hasRecipient | hasSignature // Very large payload length maliciousData.append(0xFF) // high byte maliciousData.append(0xFE) // low byte (65534) // SenderID (8 bytes) for _ in 0..<8 { maliciousData.append(0x01) } // RecipientID (8 bytes - required due to flag) for _ in 0..<8 { maliciousData.append(0x02) } // Provide minimal payload data - should trigger bounds check failure maliciousData.append(contentsOf: [0x01, 0x02]) // Should handle gracefully without integer overflow issues let result = BinaryProtocol.decode(maliciousData) #expect(result == nil, "Malicious packet designed for integer overflow should return nil, not crash") } @Test("Test packets with incomplete headers") func partialHeaderData() throws { let headerSizes = [0, 1, 5, 10, 12] // Various incomplete header sizes for size in headerSizes { let partialData = Data(repeating: 0x01, count: size) let result = BinaryProtocol.decode(partialData) #expect(result == nil, "Partial header data (\(size) bytes) should return nil, not crash") } } @Test("Test exact boundary conditions") func boundaryConditions() throws { let packet = TestHelpers.createTestPacket() let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet") // If truncation only removes padding, decode may still succeed. Compute unpadded size. let unpadded = MessagePadding.unpad(validEncoded) // Truncate within the unpadded frame to guarantee corruption let cut = max(1, unpadded.count - 10) let truncatedCore = unpadded.prefix(cut) let result = BinaryProtocol.decode(truncatedCore) #expect(result == nil, "Truncated core frame should return nil, not crash") // Test minimum valid size - create a valid minimal packet var minData = Data() minData.append(1) // version minData.append(1) // type minData.append(10) // ttl // Timestamp (8 bytes) for _ in 0..<8 { minData.append(0) } minData.append(0) // flags (no optional fields) minData.append(0) // payload length high byte minData.append(0) // payload length low byte (0 payload) // SenderID (8 bytes) for _ in 0..<8 { minData.append(0x01) } // This should be exactly the minimum size and should decode without crashing _ = BinaryProtocol.decode(minData) // The important thing is no crash occurs - result might be nil or valid // We don't assert the result, just that no crash happens } } ================================================ FILE: bitchatTests/ProtocolContractTests.swift ================================================ import Testing import Foundation import Combine import CoreBluetooth @testable import bitchat private final class DefaultDelegateProbe: BitchatDelegate { func didReceiveMessage(_ message: BitchatMessage) {} func didConnectToPeer(_ peerID: PeerID) {} func didDisconnectFromPeer(_ peerID: PeerID) {} func didUpdatePeerList(_ peers: [PeerID]) {} func didUpdateBluetoothState(_ state: CBManagerState) {} } private final class DefaultTransportProbe: Transport { weak var delegate: BitchatDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? let subject = CurrentValueSubject<[TransportPeerSnapshot], Never>([]) let myPeerID = PeerID(str: "0011223344556677") var myNickname = "Tester" private let keychain = MockKeychain() private(set) var sentMessages: [(content: String, mentions: [String])] = [] var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { subject.eraseToAnyPublisher() } func currentPeerSnapshots() -> [TransportPeerSnapshot] { subject.value } func setNickname(_ nickname: String) { myNickname = nickname } func startServices() {} func stopServices() {} func emergencyDisconnectAll() {} func isPeerConnected(_ peerID: PeerID) -> Bool { false } func isPeerReachable(_ peerID: PeerID) -> Bool { false } func peerNickname(peerID: PeerID) -> String? { nil } func getPeerNicknames() -> [PeerID: String] { [:] } func getFingerprint(for peerID: PeerID) -> String? { nil } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func triggerHandshake(with peerID: PeerID) {} func getNoiseService() -> NoiseEncryptionService { NoiseEncryptionService(keychain: keychain) } func sendMessage(_ content: String, mentions: [String]) { sentMessages.append((content, mentions)) } func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {} func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {} func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {} func sendBroadcastAnnounce() {} func sendDeliveryAck(for messageID: String, to peerID: PeerID) {} } struct ProtocolContractTests { @Test func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() { #expect(CommandInfo.message.id == "dm") #expect(CommandInfo.message.alias == "/dm") #expect(CommandInfo.message.placeholder != nil) #expect(CommandInfo.clear.placeholder == nil) #expect(CommandInfo.favorite.description.isEmpty == false) #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false) #expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite)) #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite)) } @Test func protocolEnums_andDelegateDefaults_haveStableContracts() { let delegate = DefaultDelegateProbe() let peerID = PeerID(str: "8899aabbccddeeff") #expect(MessageType.requestSync.description == "requestSync") #expect(NoisePayloadType.verifyResponse.description == "verifyResponse") #expect(DeliveryStatus.sending.displayText == "Sending...") #expect(DeliveryStatus.sent.displayText == "Sent") #expect(DeliveryStatus.delivered(to: "Alice", at: Date()).displayText == "Delivered to Alice") #expect(DeliveryStatus.read(by: "Bob", at: Date()).displayText == "Read by Bob") #expect(DeliveryStatus.failed(reason: "oops").displayText == "Failed: oops") #expect(DeliveryStatus.partiallyDelivered(reached: 1, total: 3).displayText == "Delivered to 1/3") #expect(delegate.isFavorite(fingerprint: "fp") == false) delegate.didUpdateMessageDeliveryStatus("msg-1", status: .sent) delegate.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(), timestamp: Date()) delegate.didReceivePublicMessage(from: peerID, nickname: "Alice", content: "hi", timestamp: Date(), messageID: "msg-1") } @Test func transportDefaults_forwardOrNoOp() { let probe = DefaultTransportProbe() let peerID = PeerID(str: "0123456789abcdef") let filePacket = BitchatFilePacket( fileName: "voice.m4a", fileSize: 4, mimeType: "audio/mp4", content: Data([1, 2, 3, 4]) ) probe.sendMessage("hello", mentions: ["@alice"], messageID: "msg-1", timestamp: Date()) probe.sendVerifyChallenge(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x01])) probe.sendVerifyResponse(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x02])) probe.sendFileBroadcast(filePacket, transferId: "tx-1") probe.sendFilePrivate(filePacket, to: peerID, transferId: "tx-2") probe.cancelTransfer("tx-3") probe.declinePendingFile(id: "pending") #expect(probe.sentMessages.count == 1) #expect(probe.sentMessages.first?.content == "hello") #expect(probe.acceptPendingFile(id: "pending") == nil) } @Test func previewMessage_exposesStableSampleShape() { let preview = BitchatMessage.preview #expect(preview.sender == "John Doe") #expect(preview.content == "Hello") #expect(preview.deliveryStatus == .sent) #expect(preview.isPrivate == false) } } ================================================ FILE: bitchatTests/Protocols/BinaryEncodingUtilsTests.swift ================================================ import Foundation import XCTest @testable import bitchat final class BinaryEncodingUtilsTests: XCTestCase { func test_appendAndReadPrimitiveValues_roundTrip() throws { var data = Data() data.appendUInt8(0x12) data.appendUInt16(0x3456) data.appendUInt32(0x789ABCDE) data.appendUInt64(0x0123456789ABCDEF) var offset = 0 XCTAssertEqual(data.readUInt8(at: &offset), 0x12) XCTAssertEqual(data.readUInt16(at: &offset), 0x3456) XCTAssertEqual(data.readUInt32(at: &offset), 0x789ABCDE) XCTAssertEqual(data.readUInt64(at: &offset), 0x0123456789ABCDEF) XCTAssertEqual(offset, data.count) } func test_appendAndReadStringDataAndDate_roundTrip() throws { let expectedDate = Date(timeIntervalSince1970: 1_700_000_000.123) let expectedPayload = Data([0xAA, 0xBB, 0xCC, 0xDD]) var data = Data() data.appendString("hello") data.appendData(expectedPayload) data.appendDate(expectedDate) var offset = 0 XCTAssertEqual(data.readString(at: &offset), "hello") XCTAssertEqual(data.readData(at: &offset), expectedPayload) let decodedDate = try XCTUnwrap(data.readDate(at: &offset)) XCTAssertEqual(decodedDate.timeIntervalSince1970, expectedDate.timeIntervalSince1970, accuracy: 0.001) } func test_appendUUID_and_readUUID_roundTrip() throws { let uuid = "12345678-90ab-cdef-1234-567890abcdef" var data = Data() data.appendUUID(uuid) var offset = 0 XCTAssertEqual(data.readUUID(at: &offset), uuid.uppercased()) } func test_appendStringAndData_truncateToConfiguredMaxLength() throws { var data = Data() data.appendString("abcdef", maxLength: 4) data.appendData(Data([1, 2, 3, 4, 5]), maxLength: 3) var offset = 0 XCTAssertEqual(data.readString(at: &offset), "abcd") XCTAssertEqual(data.readData(at: &offset, maxLength: 3), Data([1, 2, 3])) } func test_readMethods_returnNilWhenOutOfBounds() { var offset = 0 let shortData = Data([0x01]) XCTAssertNil(shortData.readUInt16(at: &offset)) XCTAssertEqual(offset, 0) offset = 0 XCTAssertNil(shortData.readString(at: &offset)) XCTAssertEqual(offset, 1) offset = 0 XCTAssertNil(shortData.readFixedBytes(at: &offset, count: 2)) XCTAssertEqual(offset, 0) } func test_sha256Hex_andExtendedLengthStringRoundTrip() throws { XCTAssertEqual( Data("abc".utf8).sha256Hex(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" ) var data = Data() data.appendString("hello", maxLength: 300) var offset = 0 XCTAssertEqual(data.readString(at: &offset, maxLength: 300), "hello") } func test_readString_returnsNilForInvalidUTF8ExtendedPayload() { let invalidUTF8 = Data([0x00, 0x02, 0xFF, 0xFF]) var offset = 0 XCTAssertNil(invalidUTF8.readString(at: &offset, maxLength: 300)) XCTAssertEqual(offset, invalidUTF8.count) } } ================================================ FILE: bitchatTests/Protocols/BitchatFilePacketTests.swift ================================================ import XCTest @testable import bitchat final class BitchatFilePacketTests: XCTestCase { func testRoundTripPreservesFields() throws { let content = Data((0..<4096).map { UInt8($0 % 251) }) let packet = BitchatFilePacket( fileName: "sample.jpg", fileSize: UInt64(content.count), mimeType: "image/jpeg", content: content ) guard let encoded = packet.encode() else { return XCTFail("Failed to encode file packet") } guard let decoded = BitchatFilePacket.decode(encoded) else { return XCTFail("Failed to decode file packet") } XCTAssertEqual(decoded.fileName, packet.fileName) XCTAssertEqual(decoded.fileSize, packet.fileSize) XCTAssertEqual(decoded.mimeType, packet.mimeType) XCTAssertEqual(decoded.content, packet.content) } func testDecodeFallsBackToContentSizeWhenFileSizeMissing() throws { let content = Data(repeating: 0x7F, count: 1024) let packet = BitchatFilePacket( fileName: nil, fileSize: nil, mimeType: nil, content: content ) guard let encoded = packet.encode() else { return XCTFail("Failed to encode file packet") } guard let decoded = BitchatFilePacket.decode(encoded) else { return XCTFail("Failed to decode file packet") } XCTAssertEqual(decoded.fileSize, UInt64(content.count)) XCTAssertEqual(decoded.content, content) } func testDecodeSupportsLegacyEightByteFileSizeTLV() throws { let content = Data([0x01, 0x02, 0x03, 0x04]) var data = Data() data.append(0x02) data.append(contentsOf: [0x00, 0x08]) data.append(contentsOf: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00]) data.append(0x04) data.append(contentsOf: [0x00, 0x00, 0x00, 0x04]) data.append(content) let decoded = try XCTUnwrap(BitchatFilePacket.decode(data)) XCTAssertEqual(decoded.fileSize, 256) XCTAssertEqual(decoded.content, content) } func testDecodeUsesContentCountWhenFileSizeTLVIsMissing() throws { let content = Data([0xAA, 0xBB, 0xCC]) var data = Data() data.append(0x04) data.append(contentsOf: [0x00, 0x00, 0x00, 0x03]) data.append(content) let decoded = try XCTUnwrap(BitchatFilePacket.decode(data)) XCTAssertEqual(decoded.fileSize, UInt64(content.count)) XCTAssertEqual(decoded.content, content) } } ================================================ FILE: bitchatTests/Protocols/LocationChannelTests.swift ================================================ import Foundation import Testing @testable import bitchat struct LocationChannelTests { @Test func geohashChannelLevelDisplayNamesAndLegacyDecoding() throws { for level in GeohashChannelLevel.allCases { #expect(level.displayName.isEmpty == false) } #expect(try decodeLevel(from: "\"building\"") == .building) #expect(try decodeLevel(from: "\"block\"") == .block) #expect(try decodeLevel(from: "\"neighborhood\"") == .neighborhood) #expect(try decodeLevel(from: "\"city\"") == .city) #expect(try decodeLevel(from: "\"province\"") == .province) #expect(try decodeLevel(from: "\"region\"") == .province) #expect(try decodeLevel(from: "\"country\"") == .region) #expect(try decodeLevel(from: "\"unknown\"") == .block) #expect(try decodeLevel(from: "8") == .building) #expect(try decodeLevel(from: "7") == .block) #expect(try decodeLevel(from: "6") == .neighborhood) #expect(try decodeLevel(from: "5") == .city) #expect(try decodeLevel(from: "4") == .province) #expect(try decodeLevel(from: "3") == .region) #expect(try decodeLevel(from: "0") == .region) #expect(try decodeLevel(from: "99") == .block) #expect(try decodeLevel(from: "true") == .block) } @Test func geohashChannelAndChannelIDExposeStableAccessors() { let channel = GeohashChannel(level: .city, geohash: "u4pru") #expect(channel.id == "city-u4pru") #expect(channel.displayName.contains("u4pru")) #expect(channel.displayName.contains(channel.level.displayName)) let mesh = ChannelID.mesh #expect(mesh.displayName == "Mesh") #expect(mesh.nostrGeohashTag == nil) #expect(mesh.isMesh) #expect(mesh.isLocation == false) let location = ChannelID.location(channel) #expect(location.displayName == channel.displayName) #expect(location.nostrGeohashTag == "u4pru") #expect(location.isMesh == false) #expect(location.isLocation) } private func decodeLevel(from json: String) throws -> GeohashChannelLevel { try JSONDecoder().decode(GeohashChannelLevel.self, from: Data(json.utf8)) } } ================================================ FILE: bitchatTests/Protocols/PacketsTests.swift ================================================ import Foundation import Testing @testable import bitchat struct PacketsTests { @Test func announcementPacketRoundTripsNeighborsAndSkipsUnknownTLVs() throws { let neighbors = (0..<12).map { index in Data(repeating: UInt8(index), count: 8) } let packet = AnnouncementPacket( nickname: "alice", noisePublicKey: Data(repeating: 0x11, count: 32), signingPublicKey: Data(repeating: 0x22, count: 32), directNeighbors: neighbors ) var encoded = try #require(packet.encode()) encoded.append(makeTLV(type: 0xFF, value: Data([0xAB]))) let decoded = try #require(AnnouncementPacket.decode(from: encoded)) #expect(decoded.nickname == "alice") #expect(decoded.noisePublicKey == Data(repeating: 0x11, count: 32)) #expect(decoded.signingPublicKey == Data(repeating: 0x22, count: 32)) #expect(decoded.directNeighbors?.count == 10) #expect(decoded.directNeighbors?.first == neighbors.first) #expect(decoded.directNeighbors?.last == neighbors[9]) } @Test func announcementPacketEncodeRejectsOversizedFieldsAndInvalidNeighborGroups() { let oversizedNickname = String(repeating: "a", count: 256) let validKey = Data(repeating: 0x44, count: 32) #expect( AnnouncementPacket( nickname: oversizedNickname, noisePublicKey: validKey, signingPublicKey: validKey, directNeighbors: nil ).encode() == nil ) #expect( AnnouncementPacket( nickname: "alice", noisePublicKey: Data(repeating: 0x55, count: 256), signingPublicKey: validKey, directNeighbors: nil ).encode() == nil ) #expect( AnnouncementPacket( nickname: "alice", noisePublicKey: validKey, signingPublicKey: Data(repeating: 0x66, count: 256), directNeighbors: nil ).encode() == nil ) let invalidNeighborPacket = AnnouncementPacket( nickname: "alice", noisePublicKey: validKey, signingPublicKey: validKey, directNeighbors: [Data([0x01, 0x02, 0x03])] ) let encodedWithoutNeighbors = AnnouncementPacket( nickname: "alice", noisePublicKey: validKey, signingPublicKey: validKey, directNeighbors: nil ).encode() #expect(invalidNeighborPacket.encode() == encodedWithoutNeighbors) } @Test func announcementPacketDecodeRejectsMissingFieldsAndTruncation() throws { let missingSigningKey = makeTLV(type: 0x01, value: Data("alice".utf8)) + makeTLV(type: 0x02, value: Data(repeating: 0x11, count: 32)) #expect(AnnouncementPacket.decode(from: missingSigningKey) == nil) let validPacket = try #require( AnnouncementPacket( nickname: "alice", noisePublicKey: Data(repeating: 0x11, count: 32), signingPublicKey: Data(repeating: 0x22, count: 32), directNeighbors: nil ).encode() ) #expect(AnnouncementPacket.decode(from: validPacket.dropLast()) == nil) } @Test func announcementPacketDecodeIgnoresInvalidNeighborLengths() throws { var encoded = try #require( AnnouncementPacket( nickname: "alice", noisePublicKey: Data(repeating: 0x11, count: 32), signingPublicKey: Data(repeating: 0x22, count: 32), directNeighbors: nil ).encode() ) encoded.append(makeTLV(type: 0x04, value: Data(repeating: 0x99, count: 7))) let decoded = try #require(AnnouncementPacket.decode(from: encoded)) #expect(decoded.directNeighbors == nil) } @Test func privateMessagePacketRejectsUnknownTypeAndTruncation() { let unknownTLV = Data([0x7F, 0x01, 0x41]) #expect(PrivateMessagePacket.decode(from: unknownTLV) == nil) let truncated = Data([0x00, 0x05, 0x61]) #expect(PrivateMessagePacket.decode(from: truncated) == nil) } private func makeTLV(type: UInt8, value: Data) -> Data { var data = Data([type, UInt8(value.count)]) data.append(value) return data } } ================================================ FILE: bitchatTests/PublicMessagePipelineTests.swift ================================================ // // PublicMessagePipelineTests.swift // bitchatTests // // Tests for PublicMessagePipeline ordering and deduplication. // import Testing import Foundation @testable import bitchat @MainActor private final class TestPipelineDelegate: PublicMessagePipelineDelegate { private let dedupService = MessageDeduplicationService() var messages: [BitchatMessage] = [] func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] { messages } func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) { self.messages = messages } func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { dedupService.normalizedContentKey(content) } func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { dedupService.contentTimestamp(forKey: key) } func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { dedupService.recordContentKey(key, timestamp: timestamp) } func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {} func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {} func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {} } struct PublicMessagePipelineTests { @Test @MainActor func flush_sortsByTimestamp() async { let pipeline = PublicMessagePipeline() let delegate = TestPipelineDelegate() pipeline.delegate = delegate let earlier = Date().addingTimeInterval(-10) let later = Date() let messageA = BitchatMessage( id: "a", sender: "A", content: "Later", timestamp: later, isRelay: false ) let messageB = BitchatMessage( id: "b", sender: "A", content: "Earlier", timestamp: earlier, isRelay: false ) pipeline.enqueue(messageA) pipeline.enqueue(messageB) pipeline.flushIfNeeded() #expect(delegate.messages.map { $0.id } == ["b", "a"]) } @Test @MainActor func flush_deduplicatesByContentWithinWindow() async { let pipeline = PublicMessagePipeline() let delegate = TestPipelineDelegate() pipeline.delegate = delegate let now = Date() let messageA = BitchatMessage( id: "a", sender: "A", content: "Same", timestamp: now, isRelay: false ) let messageB = BitchatMessage( id: "b", sender: "A", content: "Same", timestamp: now.addingTimeInterval(0.2), isRelay: false ) pipeline.enqueue(messageA) pipeline.enqueue(messageB) pipeline.flushIfNeeded() #expect(delegate.messages.count == 1) #expect(delegate.messages.first?.content == "Same") } @Test @MainActor func lateInsert_meshAppendsRecentOlderMessage() async { let pipeline = PublicMessagePipeline() let delegate = TestPipelineDelegate() pipeline.delegate = delegate pipeline.updateActiveChannel(.mesh) let base = Date() let newer = BitchatMessage( id: "new", sender: "A", content: "New", timestamp: base, isRelay: false ) let older = BitchatMessage( id: "old", sender: "A", content: "Old", timestamp: base.addingTimeInterval(-5), isRelay: false ) delegate.messages = [newer] pipeline.enqueue(older) pipeline.flushIfNeeded() #expect(delegate.messages.map { $0.id } == ["new", "old"]) } @Test @MainActor func lateInsert_locationInsertsByTimestamp() async { let pipeline = PublicMessagePipeline() let delegate = TestPipelineDelegate() pipeline.delegate = delegate pipeline.updateActiveChannel(.location(GeohashChannel(level: .city, geohash: "u4pruydq"))) let base = Date() let newer = BitchatMessage( id: "new", sender: "A", content: "New", timestamp: base, isRelay: false ) let older = BitchatMessage( id: "old", sender: "A", content: "Old", timestamp: base.addingTimeInterval(-5), isRelay: false ) delegate.messages = [newer] pipeline.enqueue(older) pipeline.flushIfNeeded() #expect(delegate.messages.map { $0.id } == ["old", "new"]) } } ================================================ FILE: bitchatTests/PublicTimelineStoreTests.swift ================================================ import Foundation import Testing @testable import bitchat @Suite("PublicTimelineStore Tests") struct PublicTimelineStoreTests { @Test("Mesh timeline deduplicates and trims to cap") func meshTimelineDeduplicatesAndTrims() { var store = PublicTimelineStore(meshCap: 2, geohashCap: 2) let first = TestHelpers.createTestMessage(content: "one") let second = TestHelpers.createTestMessage(content: "two") let third = TestHelpers.createTestMessage(content: "three") store.append(first, to: .mesh) store.append(second, to: .mesh) store.append(first, to: .mesh) store.append(third, to: .mesh) let messages = store.messages(for: .mesh) #expect(messages.map(\.content) == ["two", "three"]) } @Test("Geohash appendIfAbsent remove and clear work together") func geohashStoreSupportsAppendRemoveAndClear() { var store = PublicTimelineStore(meshCap: 2, geohashCap: 3) let geohash = "u4pruydq" let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) let first = TestHelpers.createTestMessage(content: "geo one") let second = TestHelpers.createTestMessage(content: "geo two") let didAppendFirst = store.appendIfAbsent(first, toGeohash: geohash) let didAppendDuplicate = store.appendIfAbsent(first, toGeohash: geohash) #expect(didAppendFirst) #expect(!didAppendDuplicate) store.append(second, toGeohash: geohash) let removed = store.removeMessage(withID: first.id) #expect(removed?.id == first.id) #expect(store.messages(for: channel).map(\.content) == ["geo two"]) store.clear(channel: channel) #expect(store.messages(for: channel).isEmpty) } @Test("Mutate geohash updates stored messages in place") func mutateGeohashAppliesTransformation() { var store = PublicTimelineStore(meshCap: 2, geohashCap: 3) let geohash = "u4pruydq" let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) let first = TestHelpers.createTestMessage(content: "geo one") store.append(first, toGeohash: geohash) store.mutateGeohash(geohash) { timeline in timeline.append(TestHelpers.createTestMessage(content: "geo two")) } #expect(store.messages(for: channel).map(\.content) == ["geo one", "geo two"]) } @Test("Queued geohash system messages drain once") func pendingGeohashSystemMessagesDrainOnce() { var store = PublicTimelineStore(meshCap: 1, geohashCap: 1) store.queueGeohashSystemMessage("first") store.queueGeohashSystemMessage("second") #expect(store.drainPendingGeohashSystemMessages() == ["first", "second"]) #expect(store.drainPendingGeohashSystemMessages().isEmpty) } } ================================================ FILE: bitchatTests/README.md ================================================ # Test Harness Guide This test suite uses an in-memory networking harness to make end-to-end and integration tests deterministic, fast, and race-free without touching production code. ## In-Memory Bus - **File:** `bitchatTests/Mocks/MockBLEService.swift` - **Registry/Adjacency:** Global `registry` maps `peerID` to a `MockBLEService` instance; `adjacency` records simulated links between peers. - **Setup:** Call `MockBLEService.resetTestBus()` in `setUp()` to clear state between tests. - **Topology:** Use `simulateConnectedPeer(_:)` and `simulateDisconnectedPeer(_:)` to add/remove links. `connectFullMesh()` helpers in tests build larger topologies. - **Handlers:** Tests can observe data via `messageDeliveryHandler` (decoded `BitchatMessage`) and `packetDeliveryHandler` (raw `BitchatPacket`). - **De‑duplication:** A thread-safe `seenMessageIDs` prevents duplicate deliveries during flooding/relays. ## Broadcast Flooding - **Flag:** `MockBLEService.autoFloodEnabled` - **Intent:** When `true`, public broadcasts propagate across the entire connected component (ignores TTL for reach) while still de‑duping to prevent loops. - **Usage:** Enabled in Integration tests (`setUp`) to simulate large-network broadcast; disabled in E2E tests to keep routing explicit and verify TTL behavior (see `PublicChatE2ETests.testZeroTTLNotRelayed`). ## Rehandshake Flow (Noise) - **Why:** The legacy NACK recovery path was removed; recovery now relies on Noise session rehandshake after decrypt failure or desync. - **Manager:** `NoiseSessionManager` manages per-peer sessions. - **Pattern:** On decrypt failure, proactively clear the local session and re-initiate a handshake. The peer accepts and replaces their session. - **Test:** `IntegrationTests.testRehandshakeAfterDecryptionFailure` - Corrupts ciphertext to induce a decrypt error. - Calls `removeSession(for:)` on the initiator’s manager before `initiateHandshake(with:)` to avoid `alreadyEstablished`. - Verifies encrypt/decrypt succeeds post-rehandshake. ## Tips - **Determinism:** Add small async delays only where handler installation/topology changes could race the first send. - **Scoping:** Keep `autoFloodEnabled` toggled only within Integration tests; always reset in `tearDown()` to avoid cross-test contamination. - **Direct vs Relay:** Private messages target a specific peer when adjacent; otherwise they are surfaced to neighbors for relay and, if known, also delivered to the target. ## Quick Start - Create nodes and connect them: - `let svc = MockBLEService(); svc.myPeerID = "PEER1"` - `svc.simulateConnectedPeer("PEER2")` - Observe messages: - `svc.messageDeliveryHandler = { msg in /* asserts */ }` - Enable broadcast flooding for Integration suites only: - `MockBLEService.autoFloodEnabled = true` ================================================ FILE: bitchatTests/ReadReceiptTests.swift ================================================ import Foundation import Testing @testable import bitchat @Suite("ReadReceipt Tests") struct ReadReceiptTests { @Test("JSON encode and decode round-trip stable fields") func jsonRoundTrip() throws { let receipt = ReadReceipt( originalMessageID: UUID().uuidString, readerID: PeerID(str: "0123456789abcdef"), readerNickname: "Alice" ) let encoded = try #require(receipt.encode(), "Receipt should encode to JSON") let decoded = try #require(ReadReceipt.decode(from: encoded), "Receipt should decode from JSON") #expect(decoded.originalMessageID == receipt.originalMessageID) #expect(decoded.receiptID == receipt.receiptID) #expect(decoded.readerID == receipt.readerID) #expect(decoded.readerNickname == receipt.readerNickname) #expect(abs(decoded.timestamp.timeIntervalSince(receipt.timestamp)) < 0.001) } @Test("Binary encode and decode round-trip stable fields") func binaryRoundTrip() throws { let receipt = ReadReceipt( originalMessageID: UUID().uuidString, readerID: PeerID(str: "fedcba9876543210"), readerNickname: "Bob" ) let decoded = try #require( ReadReceipt.fromBinaryData(receipt.toBinaryData()), "Receipt should decode from binary data" ) #expect(decoded.originalMessageID == receipt.originalMessageID.uppercased()) #expect(decoded.receiptID == receipt.receiptID.uppercased()) #expect(decoded.readerID == receipt.readerID) #expect(decoded.readerNickname == receipt.readerNickname) } @Test("Binary decode rejects truncated data") func binaryDecodeRejectsTruncatedData() { #expect(ReadReceipt.fromBinaryData(Data()) == nil) #expect(ReadReceipt.fromBinaryData(Data(repeating: 0, count: 48)) == nil) } @Test("Binary decode rejects stale timestamps") func binaryDecodeRejectsStaleTimestamp() { let receipt = ReadReceipt( originalMessageID: UUID().uuidString, readerID: PeerID(str: "0011223344556677"), readerNickname: "Carol" ) var data = receipt.toBinaryData() data.replaceSubrange(40..<48, with: Data(repeating: 0, count: 8)) #expect(ReadReceipt.fromBinaryData(data) == nil) } } ================================================ FILE: bitchatTests/Services/AutocompleteServiceTests.swift ================================================ import Foundation import Testing @testable import bitchat @Suite("AutocompleteService Tests") struct AutocompleteServiceTests { @Test("Mention suggestions are sorted, capped, and include replacement range") func mentionSuggestionsAreSortedAndCapped() { let service = AutocompleteService() let text = "hi @al" let result = service.getSuggestions( for: text, peers: ["zoe", "alice", "albert", "bob", "alex", "ally", "alpha"], cursorPosition: text.count ) #expect(result.suggestions == ["@albert", "@alex", "@alice", "@ally", "@alpha"]) #expect(result.range == NSRange(location: 3, length: 3)) } @Test("Suggestions are empty when cursor is not at a trailing mention") func suggestionsRequireTrailingMentionContext() { let service = AutocompleteService() let text = "hi @al there" let result = service.getSuggestions( for: text, peers: ["alice", "albert"], cursorPosition: text.count ) #expect(result.suggestions.isEmpty) #expect(result.range == nil) } @Test("Applying suggestions replaces the range and adds command spacing only when needed") func applySuggestionReplacesRangeAndHandlesCommandSpacing() { let service = AutocompleteService() let mentionResult = service.applySuggestion("@alice", to: "hi @al", range: NSRange(location: 3, length: 3)) let msgCommand = service.applySuggestion("/msg", to: "/m", range: NSRange(location: 0, length: 2)) let clearCommand = service.applySuggestion("/clear", to: "/c", range: NSRange(location: 0, length: 2)) #expect(mentionResult == "hi @alice") #expect(msgCommand == "/msg ") #expect(clearCommand == "/clear") } } ================================================ FILE: bitchatTests/Services/FavoritesPersistenceServiceTests.swift ================================================ import XCTest @testable import bitchat @MainActor final class FavoritesPersistenceServiceTests: XCTestCase { private let storageKey = "chat.bitchat.favorites" private let serviceKey = "chat.bitchat.favorites" func test_addFavorite_persistsAndPostsNotification() throws { let keychain = MockKeychain() let service = FavoritesPersistenceService(keychain: keychain) let peerKey = Data((0..<32).map(UInt8.init)) let expectation = expectation(forNotification: .favoriteStatusChanged, object: nil) service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice") wait(for: [expectation], timeout: 1.0) XCTAssertTrue(service.isFavorite(peerKey)) XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice") XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey)) } func test_removeFavorite_preservesRelationshipWhenPeerStillFavoritesUs() { let service = FavoritesPersistenceService(keychain: MockKeychain()) let peerKey = Data((32..<64).map(UInt8.init)) service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Bob") service.addFavorite(peerNoisePublicKey: peerKey, peerNickname: "Bob") service.removeFavorite(peerNoisePublicKey: peerKey) let relationship = service.getFavoriteStatus(for: peerKey) XCTAssertNotNil(relationship) XCTAssertEqual(relationship?.peerNickname, "Bob") XCTAssertFalse(relationship?.isFavorite ?? true) XCTAssertTrue(relationship?.theyFavoritedUs ?? false) } func test_updatePeerFavoritedUs_removesRelationshipWhenNeitherSideFavorites() { let service = FavoritesPersistenceService(keychain: MockKeychain()) let peerKey = Data((64..<96).map(UInt8.init)) service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Carol") XCTAssertNotNil(service.getFavoriteStatus(for: peerKey)) service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: false, peerNickname: "Carol") XCTAssertNil(service.getFavoriteStatus(for: peerKey)) XCTAssertFalse(service.isMutualFavorite(peerKey)) } func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() { let service = FavoritesPersistenceService(keychain: MockKeychain()) let peerKey = Data((96..<128).map(UInt8.init)) service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1dan", peerNickname: "Dan") service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Dan") let relationship = service.getFavoriteStatus(forPeerID: PeerID(publicKey: peerKey)) XCTAssertEqual(relationship?.peerNickname, "Dan") XCTAssertTrue(service.isMutualFavorite(peerKey)) } func test_init_deduplicatesPersistedRelationshipsByPublicKey() throws { let keychain = MockKeychain() let peerKey = Data((128..<160).map(UInt8.init)) let older = FavoritesPersistenceService.FavoriteRelationship( peerNoisePublicKey: peerKey, peerNostrPublicKey: nil, peerNickname: "Older", isFavorite: true, theyFavoritedUs: false, favoritedAt: Date(timeIntervalSince1970: 100), lastUpdated: Date(timeIntervalSince1970: 100) ) let newer = FavoritesPersistenceService.FavoriteRelationship( peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1newer", peerNickname: "Newer", isFavorite: true, theyFavoritedUs: true, favoritedAt: Date(timeIntervalSince1970: 100), lastUpdated: Date(timeIntervalSince1970: 200) ) let encoded = try JSONEncoder().encode([older, newer]) keychain.save(key: storageKey, data: encoded, service: serviceKey, accessible: nil) let service = FavoritesPersistenceService(keychain: keychain) XCTAssertEqual(service.favorites.count, 1) XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Newer") XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNostrPublicKey, "npub1newer") let cleaned = try XCTUnwrap(keychain.load(key: storageKey, service: serviceKey)) let decoded = try JSONDecoder().decode([FavoritesPersistenceService.FavoriteRelationship].self, from: cleaned) XCTAssertEqual(decoded.count, 1) } } ================================================ FILE: bitchatTests/Services/GeohashPresenceServiceTests.swift ================================================ import Combine import XCTest @testable import bitchat @MainActor final class GeohashPresenceServiceTests: XCTestCase { func test_start_schedulesHeartbeatUsingConfiguredInterval() { let scheduler = MockGeohashPresenceScheduler() let service = makeService(scheduler: scheduler, loopMinInterval: 42, loopMaxInterval: 42) service.start() XCTAssertEqual(scheduler.intervals, [42]) } func test_handleLocationChange_invalidatesExistingTimerAndSchedulesQuickRefresh() { let scheduler = MockGeohashPresenceScheduler() let service = makeService(scheduler: scheduler, loopMinInterval: 40, loopMaxInterval: 40) service.start() let originalTimer = scheduler.timers.first service.handleLocationChange() XCTAssertEqual(scheduler.intervals, [40, 5]) XCTAssertEqual(originalTimer?.invalidateCallCount, 1) } func test_handleConnectivityChange_onlySchedulesWhenExistingTimerIsMissingOrInvalid() { let scheduler = MockGeohashPresenceScheduler() let service = makeService(scheduler: scheduler, loopMinInterval: 33, loopMaxInterval: 33) service.start() service.handleConnectivityChange() XCTAssertEqual(scheduler.intervals, [33]) scheduler.timers.last?.invalidate() service.handleConnectivityChange() XCTAssertEqual(scheduler.intervals, [33, 33]) } func test_performHeartbeat_broadcastsOnlyAllowedPrecisionChannels() async throws { let identity = try NostrIdentity.generate() let scheduler = MockGeohashPresenceScheduler() var sentGeohashes: [String] = [] var lookedUpGeohashes: [String] = [] var sleptNanoseconds: [UInt64] = [] let channels = [ GeohashChannel(level: .region, geohash: "9q"), GeohashChannel(level: .province, geohash: "9q8y"), GeohashChannel(level: .city, geohash: "9q8yy"), GeohashChannel(level: .neighborhood, geohash: "9q8yyk"), GeohashChannel(level: .block, geohash: "9q8yyk8"), GeohashChannel(level: .building, geohash: "9q8yyk8y") ] let service = makeService( scheduler: scheduler, availableChannels: channels, deriveIdentity: { _ in identity }, relayLookup: { geohash, _ in lookedUpGeohashes.append(geohash) return ["wss://\(geohash).example"] }, relaySender: { event, _ in let geohash = event.tags.first(where: { $0.first == "g" })?[1] if let geohash { sentGeohashes.append(geohash) } }, sleeper: { nanoseconds in sleptNanoseconds.append(nanoseconds) }, loopMinInterval: 17, loopMaxInterval: 17, burstMinDelay: 0, burstMaxDelay: 0 ) service.performHeartbeat() let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 } XCTAssertTrue(sentAllAllowedChannels) XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"])) XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"])) XCTAssertEqual(sleptNanoseconds.count, 3) XCTAssertEqual(scheduler.intervals, [17]) } func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async { let scheduler = MockGeohashPresenceScheduler() var sendCount = 0 let service = makeService( scheduler: scheduler, torIsReady: { false }, relaySender: { _, _ in sendCount += 1 }, loopMinInterval: 21, loopMaxInterval: 21 ) service.performHeartbeat() try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(sendCount, 0) XCTAssertEqual(scheduler.intervals, [21]) } func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async { let scheduler = MockGeohashPresenceScheduler() var sendCount = 0 let service = makeService( scheduler: scheduler, torIsForeground: { false }, relaySender: { _, _ in sendCount += 1 }, loopMinInterval: 22, loopMaxInterval: 22 ) service.performHeartbeat() try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(sendCount, 0) XCTAssertEqual(scheduler.intervals, [22]) } func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws { let identity = try NostrIdentity.generate() var sendCount = 0 let service = makeService( scheduler: MockGeohashPresenceScheduler(), deriveIdentity: { _ in identity }, relayLookup: { _, _ in [] }, relaySender: { _, _ in sendCount += 1 } ) service.broadcastPresence(for: "9q8yy") try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(sendCount, 0) } func test_broadcastPresence_skipsSendWhenIdentityDerivationFails() async { enum PresenceError: Error { case failed } var sendCount = 0 let service = makeService( scheduler: MockGeohashPresenceScheduler(), deriveIdentity: { _ in throw PresenceError.failed }, relaySender: { _, _ in sendCount += 1 } ) service.broadcastPresence(for: "9q8yy") try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(sendCount, 0) } private func makeService( scheduler: MockGeohashPresenceScheduler, availableChannels: [GeohashChannel] = [ GeohashChannel(level: .city, geohash: "9q8yy") ], torIsReady: @escaping () -> Bool = { true }, torIsForeground: @escaping () -> Bool = { true }, deriveIdentity: @escaping (String) throws -> NostrIdentity = { _ in try NostrIdentity.generate() }, relayLookup: @escaping (String, Int) -> [String] = { geohash, _ in ["wss://\(geohash).example"] }, relaySender: @escaping (NostrEvent, [String]) -> Void = { _, _ in }, sleeper: @escaping (UInt64) async -> Void = { _ in }, loopMinInterval: TimeInterval = 40, loopMaxInterval: TimeInterval = 40, burstMinDelay: TimeInterval = 0, burstMaxDelay: TimeInterval = 0 ) -> GeohashPresenceService { let locationSubject = PassthroughSubject<[GeohashChannel], Never>() let torReadySubject = PassthroughSubject() return GeohashPresenceService( availableChannelsProvider: { availableChannels }, locationChanges: locationSubject.eraseToAnyPublisher(), torReadyPublisher: torReadySubject.eraseToAnyPublisher(), torIsReady: torIsReady, torIsForeground: torIsForeground, deriveIdentity: deriveIdentity, relayLookup: relayLookup, relaySender: relaySender, sleeper: sleeper, scheduleTimer: scheduler.schedule(interval:handler:), loopMinInterval: loopMinInterval, loopMaxInterval: loopMaxInterval, burstMinDelay: burstMinDelay, burstMaxDelay: burstMaxDelay ) } private func waitUntil( timeout: TimeInterval = 1.0, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if condition() { return true } try? await Task.sleep(nanoseconds: 10_000_000) } return condition() } } private final class MockGeohashPresenceScheduler { private(set) var intervals: [TimeInterval] = [] private(set) var timers: [MockGeohashPresenceTimer] = [] func schedule(interval: TimeInterval, handler: @escaping () -> Void) -> GeohashPresenceTimerProtocol { intervals.append(interval) let timer = MockGeohashPresenceTimer(handler: handler) timers.append(timer) return timer } } private final class MockGeohashPresenceTimer: GeohashPresenceTimerProtocol { private let handler: () -> Void private(set) var isValid = true private(set) var invalidateCallCount = 0 init(handler: @escaping () -> Void) { self.handler = handler } func invalidate() { invalidateCallCount += 1 isValid = false } func fire() { handler() } } ================================================ FILE: bitchatTests/Services/LocationStateManagerTests.swift ================================================ import CoreLocation import MapKit import XCTest @testable import bitchat @MainActor final class LocationStateManagerTests: XCTestCase { func test_loadPersistedState_normalizesBookmarksAndRestoresTeleportedSelection() async throws { let storage = makeStorage() let selected = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pru")) storage.set(try JSONEncoder().encode(selected), forKey: "locationChannel.selected") storage.set(try JSONEncoder().encode(["u4pru"]), forKey: "locationChannel.teleportedSet") storage.set(try JSONEncoder().encode(["#U4PRU", "u4pru", ""]), forKey: "locationChannel.bookmarks") let manager = LocationStateManager( storage: storage, locationManager: MockLocationManager(authorizationStatus: .denied), geocoder: MockLocationGeocoder(), shouldInitializeCoreLocation: true ) let deniedLoaded = await waitUntil { manager.permissionState == .denied } XCTAssertTrue(deniedLoaded) XCTAssertEqual(manager.bookmarks, ["u4pru"]) XCTAssertEqual(manager.selectedChannel, selected) let teleportedLoaded = await waitUntil { manager.teleported } XCTAssertTrue(teleportedLoaded) } func test_enableLocationChannels_requestsAuthorizationWhenStatusIsUndetermined() { let locationManager = MockLocationManager(authorizationStatus: .notDetermined) let manager = LocationStateManager( storage: makeStorage(), locationManager: locationManager, geocoder: MockLocationGeocoder(), shouldInitializeCoreLocation: true ) manager.enableLocationChannels() XCTAssertEqual(locationManager.requestAuthorizationCallCount, 1) XCTAssertEqual(locationManager.requestLocationCallCount, 0) } func test_enableLocationChannels_requestsOneShotLocationWhenAuthorized() async { let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways) let manager = LocationStateManager( storage: makeStorage(), locationManager: locationManager, geocoder: MockLocationGeocoder(), shouldInitializeCoreLocation: true ) let authorizedLoaded = await waitUntil { manager.permissionState == .authorized } XCTAssertTrue(authorizedLoaded) manager.enableLocationChannels() XCTAssertEqual(locationManager.requestLocationCallCount, 1) XCTAssertEqual(manager.permissionState, .authorized) } func test_beginAndEndLiveRefresh_adjustLocationManagerMode() async { let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways) let manager = LocationStateManager( storage: makeStorage(), locationManager: locationManager, geocoder: MockLocationGeocoder(), shouldInitializeCoreLocation: true ) let authorizedLoaded = await waitUntil { manager.permissionState == .authorized } XCTAssertTrue(authorizedLoaded) manager.beginLiveRefresh() XCTAssertEqual(locationManager.startUpdatingLocationCallCount, 1) XCTAssertEqual(locationManager.requestLocationCallCount, 1) XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyNearestTenMeters) XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterLiveMeters) manager.endLiveRefresh() XCTAssertEqual(locationManager.stopUpdatingLocationCallCount, 1) XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyHundredMeters) XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters) } func test_didUpdateLocations_computesChannelsAndReverseGeocodesFriendlyNames() async { let geocoder = MockLocationGeocoder() geocoder.enqueue( placemarks: [ makePlacemark( country: "United States", administrativeArea: "Hawaii", locality: "Honolulu", subLocality: "Waikiki", name: "Hilton Hawaiian Village" ) ] ) let manager = LocationStateManager( storage: makeStorage(), locationManager: MockLocationManager(authorizationStatus: .authorizedAlways), geocoder: geocoder, shouldInitializeCoreLocation: true ) let location = CLLocation(latitude: 21.2850, longitude: -157.8357) manager.locationManager(CLLocationManager(), didUpdateLocations: [location]) let channelsAndNamesLoaded = await waitUntil { manager.availableChannels.count == GeohashChannelLevel.allCases.count && manager.locationNames[.city] == "Honolulu" && manager.locationNames[.building] == "Hilton Hawaiian Village" } XCTAssertTrue(channelsAndNamesLoaded) XCTAssertEqual(geocoder.cancelCallCount, 1) XCTAssertEqual(geocoder.reverseRequests.count, 1) XCTAssertEqual(manager.availableChannels.map(\.geohash.count), GeohashChannelLevel.allCases.map(\.precision)) XCTAssertEqual(manager.locationNames[.region], "United States") XCTAssertEqual(manager.locationNames[.province], "Hawaii") XCTAssertEqual(manager.locationNames[.city], "Honolulu") XCTAssertEqual(manager.locationNames[.neighborhood], "Waikiki") XCTAssertEqual(manager.locationNames[.block], "Waikiki") XCTAssertEqual(manager.locationNames[.building], "Hilton Hawaiian Village") } func test_selectingInRegionChannel_clearsTeleportedPersistence() async { let storage = makeStorage() let manager = LocationStateManager( storage: storage, locationManager: MockLocationManager(authorizationStatus: .authorizedAlways), geocoder: MockLocationGeocoder(), shouldInitializeCoreLocation: true ) let coordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) let cityGeohash = Geohash.encode( latitude: coordinate.latitude, longitude: coordinate.longitude, precision: GeohashChannelLevel.city.precision ) let channel = GeohashChannel(level: .city, geohash: cityGeohash) manager.locationManager(CLLocationManager(), didUpdateLocations: [CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)]) let channelAvailable = await waitUntil { manager.availableChannels.contains(channel) } XCTAssertTrue(channelAvailable) manager.markTeleported(for: cityGeohash, true) manager.select(.location(channel)) let selectionSettled = await waitUntil { manager.selectedChannel == .location(channel) && !manager.teleported } XCTAssertTrue(selectionSettled) let reloaded = LocationStateManager( storage: storage, locationManager: MockLocationManager(authorizationStatus: .denied), geocoder: MockLocationGeocoder(), shouldInitializeCoreLocation: true ) let reloadedDenied = await waitUntil { reloaded.permissionState == .denied } XCTAssertTrue(reloadedDenied) XCTAssertEqual(reloaded.selectedChannel, .location(channel)) XCTAssertFalse(reloaded.teleported) } func test_addBookmark_lowPrecisionResolvesCompositeAdminName() async { let geocoder = MockLocationGeocoder() geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")]) geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")]) geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")]) geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Arizona")]) geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")]) let manager = LocationStateManager( storage: makeStorage(), locationManager: MockLocationManager(authorizationStatus: .denied), geocoder: geocoder, shouldInitializeCoreLocation: false ) manager.addBookmark("9q") let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "California and Nevada" } XCTAssertTrue(bookmarkResolved) XCTAssertEqual(geocoder.reverseRequests.count, 5) XCTAssertEqual(manager.bookmarks, ["9q"]) } private func makeStorage() -> UserDefaults { let suiteName = "LocationStateManagerTests-\(UUID().uuidString)" let storage = UserDefaults(suiteName: suiteName)! storage.removePersistentDomain(forName: suiteName) addTeardownBlock { storage.removePersistentDomain(forName: suiteName) } return storage } private func makePlacemark( country: String? = nil, administrativeArea: String? = nil, locality: String? = nil, subLocality: String? = nil, name: String? = nil ) -> CLPlacemark { var address: [String: Any] = [:] if let country { address["Country"] = country } if let administrativeArea { address["State"] = administrativeArea } if let locality { address["City"] = locality } if let subLocality { address["SubLocality"] = subLocality } if let name { address["Name"] = name } let placemark = MKPlacemark( coordinate: CLLocationCoordinate2D(latitude: 21.2850, longitude: -157.8357), addressDictionary: address ) return CLPlacemark(placemark: placemark) } private func waitUntil( timeout: TimeInterval = 1.0, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if condition() { return true } try? await Task.sleep(nanoseconds: 10_000_000) } return condition() } } private final class MockLocationManager: LocationStateManaging { weak var delegate: CLLocationManagerDelegate? var desiredAccuracy: CLLocationAccuracy = 0 var distanceFilter: CLLocationDistance = 0 var authorizationStatus: CLAuthorizationStatus private(set) var requestAuthorizationCallCount = 0 private(set) var requestLocationCallCount = 0 private(set) var startUpdatingLocationCallCount = 0 private(set) var stopUpdatingLocationCallCount = 0 init(authorizationStatus: CLAuthorizationStatus) { self.authorizationStatus = authorizationStatus } func requestWhenInUseAuthorization() { requestAuthorizationCallCount += 1 } func requestLocation() { requestLocationCallCount += 1 } func startUpdatingLocation() { startUpdatingLocationCallCount += 1 } func stopUpdatingLocation() { stopUpdatingLocationCallCount += 1 } } private final class MockLocationGeocoder: LocationStateGeocoding { private struct Response { let placemarks: [CLPlacemark]? let error: Error? } private(set) var cancelCallCount = 0 private(set) var reverseRequests: [CLLocation] = [] private var responses: [Response] = [] func enqueue(placemarks: [CLPlacemark]?, error: Error? = nil) { responses.append(Response(placemarks: placemarks, error: error)) } func cancelGeocode() { cancelCallCount += 1 } func reverseGeocodeLocation( _ location: CLLocation, completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void ) { reverseRequests.append(location) let response = responses.isEmpty ? Response(placemarks: nil, error: nil) : responses.removeFirst() completionHandler(response.placemarks, response.error) } } ================================================ FILE: bitchatTests/Services/MeshTopologyTrackerTests.swift ================================================ // // MeshTopologyTrackerTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Testing import Foundation @testable import bitchat struct MeshTopologyTrackerTests { private func hex(_ value: String) throws -> Data { try #require(Data(hexString: value)) } @Test func directLinkProducesRoute() throws { let tracker = MeshTopologyTracker() let a = try hex("0102030405060708") let b = try hex("1112131415161718") // Bidirectional announcement tracker.updateNeighbors(for: a, neighbors: [b]) tracker.updateNeighbors(for: b, neighbors: [a]) let route = try #require(tracker.computeRoute(from: a, to: b)) // Direct connection returns empty route (no intermediate hops) #expect(route == []) } @Test func multiHopRouteComputation() throws { let tracker = MeshTopologyTracker() let a = try hex("0001020304050607") let b = try hex("1011121314151617") let c = try hex("2021222324252627") let d = try hex("3031323334353637") // Bidirectional announcements for A-B, B-C, C-D tracker.updateNeighbors(for: a, neighbors: [b]) tracker.updateNeighbors(for: b, neighbors: [a, c]) tracker.updateNeighbors(for: c, neighbors: [b, d]) tracker.updateNeighbors(for: d, neighbors: [c]) let route = try #require(tracker.computeRoute(from: a, to: d)) // Route should only contain intermediate hops (b, c), excluding start (a) and end (d) #expect(route == [b, c]) } @Test func unconfirmedEdgeDoesNotRoute() throws { let tracker = MeshTopologyTracker() let a = try hex("0101010101010101") let b = try hex("0202020202020202") let c = try hex("0303030303030303") // A announces B (confirmed) // B announces A, C (confirmed A-B, unconfirmed B-C) // C does NOT announce B tracker.updateNeighbors(for: a, neighbors: [b]) tracker.updateNeighbors(for: b, neighbors: [a, c]) // C is silent or announces empty // Should NOT find route A->C because B->C is unconfirmed (C didn't announce B) #expect(tracker.computeRoute(from: a, to: c) == nil) // Now C announces B tracker.updateNeighbors(for: c, neighbors: [b]) // Should find route let route = try #require(tracker.computeRoute(from: a, to: c)) #expect(route == [b]) } @Test func removingPeerClearsEdges() throws { let tracker = MeshTopologyTracker() let a = try hex("0F0E0D0C0B0A0908") let b = try hex("0A0B0C0D0E0F0001") let c = try hex("0011223344556677") tracker.updateNeighbors(for: a, neighbors: [b]) tracker.updateNeighbors(for: b, neighbors: [a, c]) tracker.updateNeighbors(for: c, neighbors: [b]) let initialRoute = try #require(tracker.computeRoute(from: a, to: c)) #expect(initialRoute == [b]) tracker.removePeer(b) #expect(tracker.computeRoute(from: a, to: c) == nil) } @Test func sameStartAndEndReturnsEmptyRoute() throws { let tracker = MeshTopologyTracker() let a = try hex("0102030405060708") let b = try hex("1112131415161718") tracker.updateNeighbors(for: a, neighbors: [b]) tracker.updateNeighbors(for: b, neighbors: [a]) // When start == end, route should be empty (no intermediate hops needed) let route = try #require(tracker.computeRoute(from: a, to: a)) #expect(route == []) } } ================================================ FILE: bitchatTests/Services/MessageRouterTests.swift ================================================ // // MessageRouterTests.swift // bitchatTests // // Tests for MessageRouter transport selection and outbox behavior. // import Testing import Foundation @testable import bitchat struct MessageRouterTests { @Test @MainActor func sendPrivate_usesReachableTransport() async { let peerID = PeerID(str: "0000000000000001") let transportA = MockTransport() let transportB = MockTransport() transportB.reachablePeers.insert(peerID) let router = MessageRouter(transports: [transportA, transportB]) router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m1") #expect(transportA.sentPrivateMessages.isEmpty) #expect(transportB.sentPrivateMessages.count == 1) } @Test @MainActor func sendPrivate_queuesThenFlushesWhenReachable() async { let peerID = PeerID(str: "0000000000000002") let transport = MockTransport() let router = MessageRouter(transports: [transport]) router.sendPrivate("Queued", to: peerID, recipientNickname: "Peer", messageID: "m2") #expect(transport.sentPrivateMessages.isEmpty) transport.reachablePeers.insert(peerID) router.flushOutbox(for: peerID) #expect(transport.sentPrivateMessages.count == 1) } @Test @MainActor func sendReadReceipt_usesReachableTransport() async { let peerID = PeerID(str: "0000000000000003") let transport = MockTransport() transport.reachablePeers.insert(peerID) let router = MessageRouter(transports: [transport]) let receipt = ReadReceipt(originalMessageID: "m3", readerID: transport.myPeerID, readerNickname: "Me") router.sendReadReceipt(receipt, to: peerID) #expect(transport.sentReadReceipts.count == 1) } @Test @MainActor func sendFavoriteNotification_usesConnectedOrReachable() async { let peerID = PeerID(str: "0000000000000004") let transport = MockTransport() transport.reachablePeers.insert(peerID) let router = MessageRouter(transports: [transport]) router.sendFavoriteNotification(to: peerID, isFavorite: true) #expect(transport.sentFavoriteNotifications.count == 1) } } ================================================ FILE: bitchatTests/Services/NetworkActivationServiceTests.swift ================================================ import Combine import XCTest @testable import bitchat @MainActor final class NetworkActivationServiceTests: XCTestCase { private let torPreferenceKey = "networkActivationService.userTorEnabled" func test_start_leavesNetworkDisabledWithoutPermissionOrFavorites() { let context = makeService(permission: .denied, favorites: []) context.service.start() XCTAssertFalse(context.service.activationAllowed) XCTAssertEqual(context.torController.autoStartAllowedValues, [false]) XCTAssertEqual(context.proxyController.proxyModes, [false]) XCTAssertEqual(context.torController.startIfNeededCallCount, 0) XCTAssertEqual(context.torController.shutdownCompletelyCallCount, 1) XCTAssertEqual(context.relayController.connectCallCount, 0) XCTAssertEqual(context.relayController.disconnectCallCount, 1) } func test_start_enablesTorAndRelaysWhenAuthorized() { let context = makeService(permission: .authorized, favorites: []) context.service.start() XCTAssertTrue(context.service.activationAllowed) XCTAssertEqual(context.torController.autoStartAllowedValues, [true]) XCTAssertEqual(context.proxyController.proxyModes, [true]) XCTAssertEqual(context.torController.startIfNeededCallCount, 1) XCTAssertEqual(context.relayController.connectCallCount, 1) XCTAssertEqual(context.relayController.disconnectCallCount, 0) } func test_start_respectsStoredTorPreferenceForDirectMode() { let context = makeService(permission: .authorized, favorites: []) context.storage.set(false, forKey: torPreferenceKey) context.service.start() XCTAssertTrue(context.service.activationAllowed) XCTAssertFalse(context.service.userTorEnabled) XCTAssertEqual(context.torController.autoStartAllowedValues, [false]) XCTAssertEqual(context.proxyController.proxyModes, [false]) XCTAssertEqual(context.torController.startIfNeededCallCount, 0) XCTAssertEqual(context.torController.shutdownCompletelyCallCount, 1) XCTAssertEqual(context.relayController.connectCallCount, 1) } func test_setUserTorEnabled_postsNotificationAndReconnectsOnTransportSwitch() { let context = makeService(permission: .authorized, favorites: []) let notified = expectation(description: "Tor preference notification") let token = context.notificationCenter.addObserver( forName: .TorUserPreferenceChanged, object: nil, queue: nil ) { note in XCTAssertEqual(note.userInfo?["enabled"] as? Bool, false) notified.fulfill() } context.service.start() context.service.setUserTorEnabled(false) wait(for: [notified], timeout: 1.0) context.notificationCenter.removeObserver(token) XCTAssertFalse(context.service.userTorEnabled) XCTAssertEqual(context.storage.object(forKey: torPreferenceKey) as? Bool, false) XCTAssertEqual(Array(context.proxyController.proxyModes.suffix(2)), [true, false]) XCTAssertEqual(Array(context.torController.autoStartAllowedValues.suffix(2)), [true, false]) XCTAssertEqual(context.relayController.disconnectCallCount, 1) XCTAssertEqual(context.relayController.connectCallCount, 2) } func test_mutualFavoritesPublisher_reactivatesNetwork() async { let context = makeService(permission: .denied, favorites: []) context.service.start() XCTAssertFalse(context.service.activationAllowed) context.favoritesSubject.send([Data([0x01])]) let becameActive = await waitUntil { context.service.activationAllowed } XCTAssertTrue(becameActive) XCTAssertTrue(context.service.activationAllowed) XCTAssertTrue(context.torController.autoStartAllowedValues.contains(true)) XCTAssertTrue(context.proxyController.proxyModes.contains(true)) XCTAssertGreaterThanOrEqual(context.torController.startIfNeededCallCount, 1) XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1) } private func makeService( permission: LocationChannelManager.PermissionState, favorites: Set ) -> NetworkActivationTestContext { let suiteName = "NetworkActivationServiceTests-\(UUID().uuidString)" let storage = UserDefaults(suiteName: suiteName)! storage.removePersistentDomain(forName: suiteName) let permissionSubject = CurrentValueSubject(permission) let favoritesSubject = CurrentValueSubject, Never>(favorites) let torController = MockNetworkActivationTorController() let relayController = MockNetworkActivationRelayController() let proxyController = MockNetworkActivationProxyController() let notificationCenter = NotificationCenter() let service = NetworkActivationService( storage: storage, locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), permissionProvider: { permissionSubject.value }, mutualFavoritesProvider: { favoritesSubject.value }, torController: torController, relayController: relayController, proxyController: proxyController, notificationCenter: notificationCenter ) return NetworkActivationTestContext( service: service, storage: storage, permissionSubject: permissionSubject, favoritesSubject: favoritesSubject, torController: torController, relayController: relayController, proxyController: proxyController, notificationCenter: notificationCenter ) } private func waitUntil( timeout: TimeInterval = 1.0, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if condition() { return true } try? await Task.sleep(nanoseconds: 10_000_000) } return condition() } } @MainActor private struct NetworkActivationTestContext { let service: NetworkActivationService let storage: UserDefaults let permissionSubject: CurrentValueSubject let favoritesSubject: CurrentValueSubject, Never> let torController: MockNetworkActivationTorController let relayController: MockNetworkActivationRelayController let proxyController: MockNetworkActivationProxyController let notificationCenter: NotificationCenter } @MainActor private final class MockNetworkActivationTorController: NetworkActivationTorControlling { private(set) var autoStartAllowedValues: [Bool] = [] private(set) var startIfNeededCallCount = 0 private(set) var shutdownCompletelyCallCount = 0 func setAutoStartAllowed(_ allowed: Bool) { autoStartAllowedValues.append(allowed) } func startIfNeeded() { startIfNeededCallCount += 1 } func shutdownCompletely() { shutdownCompletelyCallCount += 1 } } @MainActor private final class MockNetworkActivationRelayController: NetworkActivationRelayControlling { private(set) var connectCallCount = 0 private(set) var disconnectCallCount = 0 func connect() { connectCallCount += 1 } func disconnect() { disconnectCallCount += 1 } } private final class MockNetworkActivationProxyController: NetworkActivationProxyControlling { private(set) var proxyModes: [Bool] = [] func setProxyMode(useTor: Bool) { proxyModes.append(useTor) } } ================================================ FILE: bitchatTests/Services/NoiseEncryptionServiceTests.swift ================================================ import Foundation import Testing @testable import bitchat @Suite("NoiseEncryptionService Tests") struct NoiseEncryptionServiceTests { @Test("Encryption status accessors cover all cases") func encryptionStatusAccessorsCoverAllCases() { #expect(EncryptionStatus.none.icon == "lock.slash") #expect(EncryptionStatus.noHandshake.icon == nil) #expect(EncryptionStatus.noiseHandshaking.icon == "lock.rotation") #expect(EncryptionStatus.noiseSecured.icon == "lock.fill") #expect(EncryptionStatus.noiseVerified.icon == "checkmark.seal.fill") #expect(!EncryptionStatus.none.description.isEmpty) #expect(!EncryptionStatus.noHandshake.description.isEmpty) #expect(!EncryptionStatus.noiseHandshaking.description.isEmpty) #expect(!EncryptionStatus.noiseSecured.description.isEmpty) #expect(!EncryptionStatus.noiseVerified.description.isEmpty) #expect(!EncryptionStatus.none.accessibilityDescription.isEmpty) #expect(!EncryptionStatus.noHandshake.accessibilityDescription.isEmpty) #expect(!EncryptionStatus.noiseHandshaking.accessibilityDescription.isEmpty) #expect(!EncryptionStatus.noiseSecured.accessibilityDescription.isEmpty) #expect(!EncryptionStatus.noiseVerified.accessibilityDescription.isEmpty) } @Test("Announce and packet signatures round-trip and detect tampering") func announceAndPacketSignaturesRoundTrip() throws { let service = NoiseEncryptionService(keychain: MockKeychain()) let signingPublicKey = service.getSigningPublicKeyData() let noisePublicKey = service.getStaticPublicKeyData() let signature = try #require( service.buildAnnounceSignature( peerID: Data([0xAA, 0xBB]), noiseKey: noisePublicKey, ed25519Key: signingPublicKey, nickname: "Alice", timestampMs: 12345 ), "Expected announce signature" ) #expect( service.verifyAnnounceSignature( signature: signature, peerID: Data([0xAA, 0xBB]), noiseKey: noisePublicKey, ed25519Key: signingPublicKey, nickname: "Alice", timestampMs: 12345, publicKey: signingPublicKey ) ) #expect( !service.verifyAnnounceSignature( signature: signature, peerID: Data([0xAA, 0xBB]), noiseKey: noisePublicKey, ed25519Key: signingPublicKey, nickname: "Mallory", timestampMs: 12345, publicKey: signingPublicKey ) ) #expect(!service.verifySignature(signature, for: Data("data".utf8), publicKey: Data([1, 2, 3]))) let packet = BitchatPacket( type: MessageType.announce.rawValue, senderID: Data([0, 1, 2, 3, 4, 5, 6, 7]), recipientID: nil, timestamp: 42, payload: Data("payload".utf8), signature: nil, ttl: 7 ) let signedPacket = try #require(service.signPacket(packet), "Expected signed packet") #expect(service.verifyPacketSignature(signedPacket, publicKey: signingPublicKey)) #expect(!service.verifyPacketSignature(packet, publicKey: signingPublicKey)) var tampered = signedPacket tampered.signature = Data(repeating: 0xFF, count: 64) #expect(!service.verifyPacketSignature(tampered, publicKey: signingPublicKey)) } @Test("Service-level handshake, encryption, and fingerprint lifecycle work") func handshakeEncryptionAndFingerprintLifecycle() async throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(str: "0011223344556677") let bobPeerID = PeerID(str: "8899aabbccddeeff") let recorder = AuthenticationRecorder() #expect(alice.onPeerAuthenticated == nil) alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:) try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID) let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 0.5) #expect(authenticated) #expect(alice.hasEstablishedSession(with: alicePeerID)) #expect(bob.hasEstablishedSession(with: bobPeerID)) #expect(alice.hasSession(with: alicePeerID)) #expect(bob.hasSession(with: bobPeerID)) #expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32) #expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32) #expect(alice.getPeerFingerprint(alicePeerID) != nil) #expect(bob.getPeerFingerprint(bobPeerID) != nil) let plaintext = Data("secret payload".utf8) let ciphertext = try alice.encrypt(plaintext, for: alicePeerID) let decrypted = try bob.decrypt(ciphertext, from: bobPeerID) #expect(decrypted == plaintext) alice.clearSession(for: alicePeerID) #expect(!alice.hasSession(with: alicePeerID)) #expect(alice.getPeerFingerprint(alicePeerID) == nil) bob.clearEphemeralStateForPanic() #expect(!bob.hasSession(with: bobPeerID)) #expect(bob.getPeerFingerprint(bobPeerID) == nil) } @Test("Encrypt without a session requests handshake and decrypt without session fails") func handshakeRequiredAndSessionNotEstablishedErrors() throws { let service = NoiseEncryptionService(keychain: MockKeychain()) let peerID = PeerID(str: "1021324354657687") var requestedPeerID: PeerID? service.onHandshakeRequired = { requestedPeerID = $0 } do { _ = try service.encrypt(Data("hello".utf8), for: peerID) Issue.record("Expected handshakeRequired error") } catch NoiseEncryptionError.handshakeRequired { #expect(requestedPeerID == peerID) } catch { Issue.record("Unexpected error: \(error)") } do { _ = try service.decrypt(Data("hello".utf8), from: peerID) Issue.record("Expected sessionNotEstablished error") } catch NoiseEncryptionError.sessionNotEstablished { // Expected } catch { Issue.record("Unexpected error: \(error)") } } @Test("Clearing persistent identity removes saved keys") func clearPersistentIdentityRemovesSavedKeys() { let keychain = MockKeychain() let service = NoiseEncryptionService(keychain: keychain) #expect(service.getStaticPublicKeyData().count == 32) #expect(service.getSigningPublicKeyData().count == 32) service.clearPersistentIdentity() if case .itemNotFound = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") { } else { Issue.record("Expected noiseStaticKey to be removed") } if case .itemNotFound = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey") { } else { Issue.record("Expected ed25519SigningKey to be removed") } } @Test("NoiseMessage JSON and binary encoding round-trip") func noiseMessageRoundTrips() throws { let message = NoiseMessage( type: .encryptedMessage, sessionID: UUID().uuidString, payload: Data([1, 2, 3, 4]) ) let encoded = try #require(message.encode(), "Expected JSON encoding") let decoded = try #require(NoiseMessage.decode(from: encoded), "Expected JSON decode") #expect(decoded.type == message.type) #expect(decoded.sessionID == message.sessionID) #expect(decoded.payload == message.payload) #expect(NoiseMessage.decodeWithError(from: Data("bad".utf8)) == nil) let binary = message.toBinaryData() let roundTripped = try #require(NoiseMessage.fromBinaryData(binary), "Expected binary decode") #expect(roundTripped.type == message.type) #expect(roundTripped.sessionID == message.sessionID) #expect(roundTripped.payload == message.payload) #expect(NoiseMessage.fromBinaryData(Data()) == nil) } private func establishSessions( alice: NoiseEncryptionService, bob: NoiseEncryptionService, alicePeerID: PeerID, bobPeerID: PeerID ) throws { let message1 = try alice.initiateHandshake(with: alicePeerID) let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1) let message2 = try #require(response, "Expected handshake response") let final = try alice.processHandshakeMessage(from: alicePeerID, message: message2) let message3 = try #require(final, "Expected handshake final") let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3) #expect(finalMessage == nil) } } private final class AuthenticationRecorder: @unchecked Sendable { private let lock = NSLock() private var entries: [(PeerID, String)] = [] var count: Int { lock.lock() defer { lock.unlock() } return entries.count } func record(peerID: PeerID, fingerprint: String) { lock.lock() entries.append((peerID, fingerprint)) lock.unlock() } } ================================================ FILE: bitchatTests/Services/NostrRelayManagerTests.swift ================================================ import Combine import XCTest @testable import bitchat @MainActor final class NostrRelayManagerTests: XCTestCase { func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async { let context = makeContext(permission: .authorized, activationAllowed: false) XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) context.activationAllowed.value = true context.manager.connect() let connected = await waitUntil { context.sessionFactory.requestedURLs.count == 5 && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connected) } func test_permissionPublisher_addsAndRemovesDefaultRelays() async { let context = makeContext(permission: .denied, favorites: []) XCTAssertEqual(context.manager.getRelayStatuses().count, 0) context.permissionSubject.send(.authorized) let defaultRelaysConnected = await waitUntil { context.manager.getRelayStatuses().count == 5 && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(defaultRelaysConnected) context.permissionSubject.send(.denied) let defaultRelaysRemoved = await waitUntil { context.manager.getRelayStatuses().isEmpty } XCTAssertTrue(defaultRelaysRemoved) XCTAssertEqual(context.sessionFactory.allConnections.count, 5) XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 }) } func test_connect_waitsForTorReadinessBeforeCreatingSessions() async { let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) context.manager.connect() XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) context.torWaiter.resolve(true) let connectedAfterTorReady = await waitUntil { context.sessionFactory.requestedURLs.count == 5 && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connectedAfterTorReady) } func test_connect_whenTorReadinessFailsDoesNotCreateSessions() async { let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) context.manager.connect() context.torWaiter.resolve(false) try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) XCTAssertFalse(context.manager.isConnected) } func test_sendEvent_waitsForTorReadinessBeforeSending() async throws { let relayURL = "wss://tor-ready.example" let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) let event = try makeSignedEvent(content: "deferred") context.manager.sendEvent(event, to: [relayURL]) XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) context.torWaiter.resolve(true) let sentAfterTorReady = await waitUntil { context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 && context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1 } XCTAssertTrue(sentAfterTorReady) } func test_sendEvent_queuesWhileBackgroundedAndFlushesWhenForegrounded() async throws { let relayURL = "wss://queue-flush.example" let context = makeContext( permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true, torIsForeground: false ) let event = try makeSignedEvent(content: "queued") context.manager.sendEvent(event, to: [relayURL]) try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) context.torForeground.value = true context.manager.ensureConnections(to: [relayURL]) let flushed = await waitUntil { context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 && context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1 } XCTAssertTrue(flushed) } func test_sendEvent_sendFailureDoesNotIncrementMessageCount() async throws { let relayURL = "wss://send-failure.example" let context = makeContext(permission: .denied) context.sessionFactory.sendErrorByURL[relayURL] = NSError(domain: "send", code: 1) let event = try makeSignedEvent(content: "send failure") context.manager.sendEvent(event, to: [relayURL]) let attempted = await waitUntil { context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 } XCTAssertTrue(attempted) try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent, 0) } func test_sendEvent_queueIsPrunedWhenDefaultRelaysAreRevoked() async throws { let context = makeContext( permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: true, torIsForeground: false ) let event = try makeSignedEvent(content: "queued default") context.manager.sendEvent(event) let queued = await waitUntil { context.manager.debugPendingMessageQueueCount == 1 } XCTAssertTrue(queued) context.permissionSubject.send(.denied) let cleared = await waitUntil { context.manager.debugPendingMessageQueueCount == 0 && context.manager.relays.isEmpty } XCTAssertTrue(cleared) } func test_connect_doesNothingWhenActivationIsDisallowed() { let context = makeContext(permission: .authorized, activationAllowed: false) context.manager.connect() XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) XCTAssertFalse(context.manager.isConnected) } func test_ensureConnections_deduplicatesRelayURLs() async { let relayOne = "wss://relay-one.example" let relayTwo = "wss://relay-two.example" let context = makeContext(permission: .denied) context.manager.ensureConnections(to: [relayOne, relayOne, relayTwo]) let connected = await waitUntil { Set(context.manager.getRelayStatuses().map(\.url)) == Set([relayOne, relayTwo]) && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connected) XCTAssertEqual(context.sessionFactory.requestedURLs, [relayOne, relayTwo]) } func test_subscribe_coalescesRapidDuplicateRequests() async { let relayURL = "wss://subscribe.example" let context = makeContext(permission: .denied) let filter = makeFilter() context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in }) let firstSent = await waitUntil { context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 } XCTAssertTrue(firstSent) context.clock.now = context.clock.now.addingTimeInterval(0.5) context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in }) XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1) } func test_subscribe_waitsForTorReadinessAndPreservesEOSECallback() async throws { let relayURL = "wss://tor-subscribe.example" let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) var eoseCount = 0 context.manager.subscribe( filter: makeFilter(), id: "tor-eose", relayUrls: [relayURL], handler: { _ in }, onEOSE: { eoseCount += 1 } ) XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) context.torWaiter.resolve(true) let subscribed = await waitUntil { context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 } XCTAssertTrue(subscribed) try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "tor-eose") let eoseCompleted = await waitUntil { eoseCount == 1 } XCTAssertTrue(eoseCompleted) } func test_subscribe_withoutAllowedRelays_callsEOSEImmediatelyAndDoesNotFlushLater() async { let context = makeContext(permission: .denied) var eoseCount = 0 context.manager.subscribe( filter: makeFilter(), id: "blocked-defaults", handler: { _ in }, onEOSE: { eoseCount += 1 } ) XCTAssertEqual(eoseCount, 1) XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) context.permissionSubject.send(.authorized) let connected = await waitUntil { context.sessionFactory.allConnections.count == 5 && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connected) XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.sentStrings.isEmpty }) } func test_permissionRevocation_clearsQueuedDefaultSubscriptions() async { let context = makeContext( permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: true, torIsForeground: false ) let defaultRelay = "wss://relay.damus.io" context.manager.subscribe(filter: makeFilter(), id: "queued-default", handler: { _ in }) let queued = await waitUntil { context.manager.debugPendingSubscriptionCount(for: defaultRelay) == 1 } XCTAssertTrue(queued) context.permissionSubject.send(.denied) let cleared = await waitUntil { context.manager.debugPendingSubscriptionCount(for: defaultRelay) == 0 && context.manager.relays.isEmpty } XCTAssertTrue(cleared) } func test_unsubscribe_allowsResubscribeWithSameID() async { let relayURL = "wss://subscribe.example" let context = makeContext(permission: .denied) let filter = makeFilter() context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in }) let initialSubscribeSent = await waitUntil { context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 } XCTAssertTrue(initialSubscribeSent) context.manager.unsubscribe(id: "sub") let closeSent = await waitUntil { context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 2 } XCTAssertTrue(closeSent) context.clock.now = context.clock.now.addingTimeInterval(0.2) context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in }) let resubscribed = await waitUntil { context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 3 } XCTAssertTrue(resubscribed) } func test_receiveEvent_deliversHandlerAndTracksReceivedCount() async throws { let relayURL = "wss://events.example" let context = makeContext(permission: .denied) let filter = makeFilter() let event = try makeSignedEvent(content: "hello") var receivedEvent: NostrEvent? context.manager.subscribe(filter: filter, id: "events", relayUrls: [relayURL]) { event in receivedEvent = event } let subscriptionSent = await waitUntil { context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 } XCTAssertTrue(subscriptionSent) try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "events", event: event) let delivered = await waitUntil { receivedEvent?.id == event.id && context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1 } XCTAssertTrue(delivered) XCTAssertEqual(receivedEvent?.id, event.id) } func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws { let relayURL = "wss://missing-handler.example" let context = makeContext(permission: .denied) let event = try makeSignedEvent(content: "unhandled") context.manager.ensureConnections(to: [relayURL]) let connected = await waitUntil { context.sessionFactory.latestConnection(for: relayURL) != nil && context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true } XCTAssertTrue(connected) try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "missing", event: event) let counted = await waitUntil { context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1 } XCTAssertTrue(counted) } func test_noticeAndMalformedMessages_keepReceiveLoopAliveForLaterEvents() async throws { let relayURL = "wss://parser.example" let context = makeContext(permission: .denied) var receivedIDs: [String] = [] let firstEvent = try makeSignedEvent(content: "after notice") let secondEvent = try makeSignedEvent(content: "after malformed") context.manager.subscribe(filter: makeFilter(), id: "parser", relayUrls: [relayURL]) { event in receivedIDs.append(event.id) } let subscribed = await waitUntil { context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 } XCTAssertTrue(subscribed) try context.sessionFactory.latestConnection(for: relayURL)?.emitNotice(message: "ignored") try? await Task.sleep(nanoseconds: 20_000_000) try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "parser", event: firstEvent) let firstDelivered = await waitUntil { receivedIDs == [firstEvent.id] } XCTAssertTrue(firstDelivered) try context.sessionFactory.latestConnection(for: relayURL)?.emitRawString("not-json") try? await Task.sleep(nanoseconds: 20_000_000) try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "parser", event: secondEvent) let secondDelivered = await waitUntil { receivedIDs == [firstEvent.id, secondEvent.id] } XCTAssertTrue(secondDelivered) } func test_okMessages_clearPendingGiftWrapIDs() async throws { let relayURL = "wss://ok.example" let context = makeContext(permission: .denied) let successID = "gift-wrap-success" let failureID = "gift-wrap-failure" context.manager.ensureConnections(to: [relayURL]) let connected = await waitUntil { context.sessionFactory.latestConnection(for: relayURL) != nil && context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true } XCTAssertTrue(connected) NostrRelayManager.registerPendingGiftWrap(id: successID) try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok") let successCleared = await waitUntil { !NostrRelayManager.pendingGiftWrapIDs.contains(successID) } XCTAssertTrue(successCleared) NostrRelayManager.registerPendingGiftWrap(id: failureID) try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected") let failureCleared = await waitUntil { !NostrRelayManager.pendingGiftWrapIDs.contains(failureID) } XCTAssertTrue(failureCleared) } func test_eoseCallback_waitsForAllTargetedRelays() async throws { let relayOne = "wss://one.example" let relayTwo = "wss://two.example" let context = makeContext(permission: .denied) var eoseCount = 0 context.manager.subscribe( filter: makeFilter(), id: "eose", relayUrls: [relayOne, relayTwo], handler: { _ in }, onEOSE: { eoseCount += 1 } ) let bothConnected = await waitUntil { context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 && context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1 } XCTAssertTrue(bothConnected) try context.sessionFactory.latestConnection(for: relayOne)?.emitEOSE(subscriptionID: "eose") try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(eoseCount, 0) try context.sessionFactory.latestConnection(for: relayTwo)?.emitEOSE(subscriptionID: "eose") let eoseCompleted = await waitUntil { eoseCount == 1 } XCTAssertTrue(eoseCompleted) } func test_eoseTimeout_invokesCallbackOnceAndIgnoresLateEOSE() async throws { let relayURL = "wss://timeout.example" let context = makeContext(permission: .denied) var eoseCount = 0 context.manager.subscribe( filter: makeFilter(), id: "timeout", relayUrls: [relayURL], handler: { _ in }, onEOSE: { eoseCount += 1 } ) let subscribed = await waitUntil { context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 } XCTAssertTrue(subscribed) let timedOut = await waitUntil(timeout: 3.0) { eoseCount == 1 } XCTAssertTrue(timedOut) try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "timeout") try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(eoseCount, 1) } func test_receiveFailure_schedulesReconnectWithBackoff() async { let relayURL = "wss://retry.example" let context = makeContext(permission: .denied) context.manager.ensureConnections(to: [relayURL]) let firstConnected = await waitUntil { context.sessionFactory.latestConnection(for: relayURL) != nil } XCTAssertTrue(firstConnected) let firstConnection = context.sessionFactory.latestConnection(for: relayURL) firstConnection?.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)) let retryScheduled = await waitUntil { context.scheduler.scheduled.count == 1 && context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 1 } XCTAssertTrue(retryScheduled) XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrRelayInitialBackoffSeconds) let initialRequestCount = context.sessionFactory.requestedURLs.count context.scheduler.runNext() let retried = await waitUntil { context.sessionFactory.requestedURLs.count == initialRequestCount + 1 } XCTAssertTrue(retried) } func test_receiveFailure_whenActivationBecomesDisallowedDoesNotScheduleReconnect() async { let relayURL = "wss://no-retry.example" let context = makeContext(permission: .denied) context.manager.ensureConnections(to: [relayURL]) let connected = await waitUntil { context.sessionFactory.latestConnection(for: relayURL) != nil && context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true } XCTAssertTrue(connected) context.activationAllowed.value = false context.sessionFactory.latestConnection(for: relayURL)?.fail( error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) ) let disconnected = await waitUntil { context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == false } XCTAssertTrue(disconnected) XCTAssertTrue(context.scheduler.scheduled.isEmpty) XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1) } func test_disconnect_invalidatesScheduledReconnectGeneration() async { let relayURL = "wss://disconnect.example" let context = makeContext(permission: .denied) context.manager.ensureConnections(to: [relayURL]) let firstConnected = await waitUntil { context.sessionFactory.latestConnection(for: relayURL) != nil } XCTAssertTrue(firstConnected) context.sessionFactory.latestConnection(for: relayURL)?.fail( error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) ) let retryScheduled = await waitUntil { context.scheduler.scheduled.count == 1 } XCTAssertTrue(retryScheduled) let requestCountBeforeDisconnect = context.sessionFactory.requestedURLs.count context.manager.disconnect() context.scheduler.runNext() try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(context.sessionFactory.requestedURLs.count, requestCountBeforeDisconnect) } func test_retryConnection_cancelsActiveConnectionBeforeReconnecting() async { let relayURL = "wss://retry-now.example" let context = makeContext(permission: .denied) context.manager.ensureConnections(to: [relayURL]) let connected = await waitUntil { context.sessionFactory.latestConnection(for: relayURL) != nil && context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true } XCTAssertTrue(connected) guard let firstConnection = context.sessionFactory.latestConnection(for: relayURL) else { XCTFail("Expected initial connection") return } let initialRequestCount = context.sessionFactory.requestedURLs.count context.manager.retryConnection(to: relayURL) let reconnected = await waitUntil { guard let latest = context.sessionFactory.latestConnection(for: relayURL) else { return false } return context.sessionFactory.requestedURLs.count == initialRequestCount + 1 && latest !== firstConnection } XCTAssertTrue(reconnected) XCTAssertEqual(firstConnection.cancelCallCount, 1) } func test_retryConnection_whenTorReadinessFailsDoesNotReconnect() async { let relayURL = "wss://retry-tor.example" let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true) context.manager.ensureConnections(to: [relayURL]) let connected = await waitUntil { context.sessionFactory.latestConnection(for: relayURL) != nil && context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true } XCTAssertTrue(connected) guard let firstConnection = context.sessionFactory.latestConnection(for: relayURL) else { XCTFail("Expected initial connection") return } let initialRequestCount = context.sessionFactory.requestedURLs.count context.torWaiter.isReady = false context.manager.retryConnection(to: relayURL) XCTAssertEqual(firstConnection.cancelCallCount, 1) XCTAssertEqual(context.sessionFactory.requestedURLs.count, initialRequestCount) context.torWaiter.resolve(false) try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(context.sessionFactory.requestedURLs.count, initialRequestCount) } func test_resetAllConnections_clearsRelayStateAndReconnects() async { let relayURL = "wss://reset.example" let context = makeContext(permission: .denied) context.manager.ensureConnections(to: [relayURL]) let connected = await waitUntil { context.sessionFactory.latestConnection(for: relayURL) != nil && context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true } XCTAssertTrue(connected) context.sessionFactory.latestConnection(for: relayURL)?.fail( error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) ) let failed = await waitUntil { context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 1 && context.manager.relays.first(where: { $0.url == relayURL })?.lastError != nil } XCTAssertTrue(failed) let requestCountBeforeReset = context.sessionFactory.requestedURLs.count context.manager.resetAllConnections() let reset = await waitUntil { context.sessionFactory.requestedURLs.count == requestCountBeforeReset + 1 && context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true && context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 0 && context.manager.relays.first(where: { $0.url == relayURL })?.nextReconnectTime == nil && context.manager.relays.first(where: { $0.url == relayURL })?.lastError == nil } XCTAssertTrue(reset) } func test_debugFlushMessageQueue_flushesAllConnectedRelays() async throws { let relayOne = "wss://flush-one.example" let relayTwo = "wss://flush-two.example" let context = makeContext( permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true, torIsForeground: false ) let event = try makeSignedEvent(content: "flush-all") context.manager.sendEvent(event, to: [relayOne, relayTwo]) let queued = await waitUntil { context.manager.debugPendingMessageQueueCount == 1 } XCTAssertTrue(queued) context.torForeground.value = true context.manager.ensureConnections(to: [relayOne, relayTwo]) context.manager.debugFlushMessageQueue() let flushed = await waitUntil { context.manager.debugPendingMessageQueueCount == 0 && context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 && context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1 } XCTAssertTrue(flushed) } func test_dnsPingFailure_marksRelayPermanentCallsEOSEImmediatelyAndManualRetryReconnects() async { let relayURL = "wss://dns-failure.example" let context = makeContext(permission: .denied) context.sessionFactory.pingErrorByURL[relayURL] = NSError( domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost, userInfo: [NSLocalizedDescriptionKey: "DNS failure"] ) context.manager.subscribe(filter: makeFilter(), id: "dns-sub", relayUrls: [relayURL], handler: { _ in }) let permanentlyFailed = await waitUntil { context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == TransportConfig.nostrRelayMaxReconnectAttempts && context.scheduler.scheduled.isEmpty } XCTAssertTrue(permanentlyFailed) var immediateEOSE = 0 context.manager.subscribe( filter: makeFilter(), id: "dns-eose", relayUrls: [relayURL], handler: { _ in }, onEOSE: { immediateEOSE += 1 } ) XCTAssertEqual(immediateEOSE, 1) context.sessionFactory.pingErrorByURL[relayURL] = nil let requestCountBeforeRetry = context.sessionFactory.requestedURLs.count context.manager.retryConnection(to: relayURL) let reconnected = await waitUntil { context.sessionFactory.requestedURLs.count == requestCountBeforeRetry + 1 && context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true && context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 0 } XCTAssertTrue(reconnected) } private func makeContext( permission: LocationChannelManager.PermissionState, favorites: Set = [], activationAllowed: Bool = true, userTorEnabled: Bool = false, torEnforced: Bool = false, torIsReady: Bool = true, torIsForeground: Bool = true ) -> RelayManagerTestContext { let permissionSubject = CurrentValueSubject(permission) let favoritesSubject = CurrentValueSubject, Never>(favorites) let sessionFactory = MockRelaySessionFactory() let scheduler = MockRelayScheduler() let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000)) let torWaiter = MockTorWaiter(isReady: torIsReady) let torForeground = MutableBool(value: torIsForeground) let activationFlag = MutableBool(value: activationAllowed) let manager = NostrRelayManager( dependencies: NostrRelayManagerDependencies( activationAllowed: { activationFlag.value }, userTorEnabled: { userTorEnabled }, hasMutualFavorites: { !favoritesSubject.value.isEmpty }, hasLocationPermission: { permissionSubject.value == .authorized }, mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), torEnforced: { torEnforced }, torIsReady: { torWaiter.isReady }, torIsForeground: { torForeground.value }, awaitTorReady: torWaiter.await(completion:), makeSession: { sessionFactory }, scheduleAfter: { delay, action in scheduler.schedule(delay: delay, action: action) }, now: { clock.now } ) ) return RelayManagerTestContext( manager: manager, permissionSubject: permissionSubject, favoritesSubject: favoritesSubject, sessionFactory: sessionFactory, scheduler: scheduler, clock: clock, activationAllowed: activationFlag, torWaiter: torWaiter, torForeground: torForeground ) } private func makeFilter() -> NostrFilter { var filter = NostrFilter() filter.kinds = [NostrProtocol.EventKind.textNote.rawValue] filter.limit = 10 return filter } private func makeSignedEvent(content: String) throws -> NostrEvent { let identity = try NostrIdentity.generate() let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .textNote, tags: [], content: content ) return try event.sign(with: identity.schnorrSigningKey()) } private func waitUntil( timeout: TimeInterval = 1.0, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if condition() { return true } try? await Task.sleep(nanoseconds: 10_000_000) } return condition() } } @MainActor private struct RelayManagerTestContext { let manager: NostrRelayManager let permissionSubject: CurrentValueSubject let favoritesSubject: CurrentValueSubject, Never> let sessionFactory: MockRelaySessionFactory let scheduler: MockRelayScheduler let clock: MutableClock let activationAllowed: MutableBool let torWaiter: MockTorWaiter let torForeground: MutableBool } private final class MutableClock { var now: Date init(now: Date) { self.now = now } } private final class MutableBool { var value: Bool init(value: Bool) { self.value = value } } private final class MockTorWaiter { private var completions: [(Bool) -> Void] = [] var isReady: Bool init(isReady: Bool) { self.isReady = isReady } func await(completion: @escaping (Bool) -> Void) { completions.append(completion) } func resolve(_ ready: Bool) { isReady = ready let pending = completions completions.removeAll() pending.forEach { $0(ready) } } } private final class MockRelayScheduler: @unchecked Sendable { struct ScheduledAction { let delay: TimeInterval let action: @Sendable () -> Void } private(set) var scheduled: [ScheduledAction] = [] func schedule(delay: TimeInterval, action: @escaping @Sendable () -> Void) { scheduled.append(ScheduledAction(delay: delay, action: action)) } func runNext() { guard !scheduled.isEmpty else { return } let next = scheduled.removeFirst() next.action() } } private final class MockRelaySessionFactory: NostrRelaySessionProtocol { private(set) var requestedURLs: [String] = [] private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:] var pingErrorByURL: [String: Error?] = [:] var sendErrorByURL: [String: Error?] = [:] var allConnections: [MockRelayConnection] { connectionsByURL.values.flatMap { $0 } } func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol { requestedURLs.append(url.absoluteString) let connection = MockRelayConnection( url: url.absoluteString, pingError: pingErrorByURL[url.absoluteString] ?? nil, sendError: sendErrorByURL[url.absoluteString] ?? nil ) connectionsByURL[url.absoluteString, default: []].append(connection) return connection } func latestConnection(for url: String) -> MockRelayConnection? { connectionsByURL[url]?.last } } private final class MockRelayConnection: NostrRelayConnectionProtocol { private let url: String private let pingError: Error? private let sendError: Error? private var receiveHandler: ((Result) -> Void)? private(set) var resumeCallCount = 0 private(set) var cancelCallCount = 0 private(set) var sentMessages: [URLSessionWebSocketTask.Message] = [] var sentStrings: [String] { sentMessages.compactMap { switch $0 { case .string(let string): string case .data(let data): String(data: data, encoding: .utf8) @unknown default: nil } } } init(url: String, pingError: Error? = nil, sendError: Error? = nil) { self.url = url self.pingError = pingError self.sendError = sendError } func resume() { resumeCallCount += 1 } func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { cancelCallCount += 1 } func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) { sentMessages.append(message) completionHandler(sendError) } func receive(completionHandler: @escaping (Result) -> Void) { receiveHandler = completionHandler } func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) { pongReceiveHandler(pingError) } func fail(error: Error) { let handler = receiveHandler receiveHandler = nil handler?(.failure(error)) } func emitEventMessage(subscriptionID: String, event: NostrEvent) throws { let eventData = try JSONEncoder().encode(event) let eventJSONObject = try JSONSerialization.jsonObject(with: eventData) as! [String: Any] let payload: [Any] = ["EVENT", subscriptionID, eventJSONObject] try emit(jsonObject: payload) } func emitEOSE(subscriptionID: String) throws { try emit(jsonObject: ["EOSE", subscriptionID]) } func emitOK(eventID: String, success: Bool, reason: String) throws { try emit(jsonObject: ["OK", eventID, success, reason]) } func emitNotice(message: String) throws { try emit(jsonObject: ["NOTICE", message]) } func emitRawString(_ string: String) throws { let handler = receiveHandler receiveHandler = nil handler?(.success(.string(string))) } private func emit(jsonObject: Any) throws { let data = try JSONSerialization.data(withJSONObject: jsonObject) let handler = receiveHandler receiveHandler = nil handler?(.success(.data(data))) } } ================================================ FILE: bitchatTests/Services/NostrTransportTests.swift ================================================ // // NostrTransportTests.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation import Testing @testable import bitchat @Suite("NostrTransport Tests") struct NostrTransportTests { typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship @Test("Warm cache marks full and short IDs reachable") @MainActor func reachabilityCacheWarmsFromFavorites() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) let recipient = try NostrIdentity.generate() let noiseKey = Data((0..<32).map(UInt8.init)) let fullPeerID = PeerID(hexData: noiseKey) let shortPeerID = fullPeerID.toShort() let relationship = makeRelationship( peerNoisePublicKey: noiseKey, peerNostrPublicKey: recipient.npub, peerNickname: "Alice" ) let favorites = [noiseKey: relationship] let transport = NostrTransport( keychain: keychain, idBridge: idBridge, dependencies: makeDependencies( loadFavorites: { favorites }, favoriteStatusForNoiseKey: { favorites[$0] }, favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil }, currentIdentity: { nil } ) ) #expect(!transport.isPeerReachable(fullPeerID)) #expect(transport.isPeerReachable(shortPeerID)) #expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed"))) } @Test("Favorite status notification refreshes reachability cache") @MainActor func favoriteStatusNotificationRefreshesReachability() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) let recipient = try NostrIdentity.generate() let noiseKey = Data((32..<64).map(UInt8.init)) let peerID = PeerID(hexData: noiseKey).toShort() let notificationCenter = NotificationCenter() var favorites: [Data: FavoriteRelationship] = [:] let transport = NostrTransport( keychain: keychain, idBridge: idBridge, dependencies: makeDependencies( notificationCenter: notificationCenter, loadFavorites: { favorites }, favoriteStatusForNoiseKey: { favorites[$0] }, favoriteStatusForPeerID: { _ in favorites.values.first }, currentIdentity: { nil } ) ) #expect(!transport.isPeerReachable(peerID)) favorites[noiseKey] = makeRelationship( peerNoisePublicKey: noiseKey, peerNostrPublicKey: recipient.npub, peerNickname: "Bob" ) notificationCenter.post(name: .favoriteStatusChanged, object: nil) let didRefresh = await TestHelpers.waitUntil({ transport.isPeerReachable(peerID) }, timeout: 0.5) #expect(didRefresh) } @Test("Private message resolves short peer ID and emits decryptable packet") @MainActor func sendPrivateMessageResolvesShortPeerID() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let noiseKey = Data((64..<96).map(UInt8.init)) let shortPeerID = PeerID(hexData: noiseKey).toShort() let relationship = makeRelationship( peerNoisePublicKey: noiseKey, peerNostrPublicKey: recipient.npub, peerNickname: "Carol" ) let probe = NostrTransportProbe() let transport = NostrTransport( keychain: keychain, idBridge: idBridge, dependencies: makeDependencies( favoriteStatusForNoiseKey: { _ in nil }, favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil }, currentIdentity: { sender }, registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), sendEvent: probe.record(event:), scheduleAfter: { delay, action in probe.enqueueScheduledAction(delay: delay, action: action) } ) ) transport.senderPeerID = PeerID(str: "0123456789abcdef") transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1") let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5) #expect(didSend) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) let privateMessage = try decodePrivateMessage(from: result.payload) #expect(result.senderPubkey == sender.publicKeyHex) #expect(privateMessage.messageID == "pm-1") #expect(privateMessage.content == "hello over nostr") #expect(result.packet.recipientID == shortPeerID.routingData) #expect(probe.pendingGiftWrapIDs.isEmpty) } @Test("Favorite notification embeds current npub") @MainActor func sendFavoriteNotificationEmbedsCurrentIdentity() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let noiseKey = Data((96..<128).map(UInt8.init)) let fullPeerID = PeerID(hexData: noiseKey) let relationship = makeRelationship( peerNoisePublicKey: noiseKey, peerNostrPublicKey: recipient.npub, peerNickname: "Dan" ) let probe = NostrTransportProbe() let transport = NostrTransport( keychain: keychain, idBridge: idBridge, dependencies: makeDependencies( favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, favoriteStatusForPeerID: { _ in nil }, currentIdentity: { sender }, registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), sendEvent: probe.record(event:), scheduleAfter: { delay, action in probe.enqueueScheduledAction(delay: delay, action: action) } ) ) transport.senderPeerID = PeerID(str: "0123456789abcdef") transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true) let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5) #expect(didSend) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) let privateMessage = try decodePrivateMessage(from: result.payload) #expect(privateMessage.content == "[FAVORITED]:\(sender.npub)") } @Test("Delivery ACK encodes delivered payload type") @MainActor func sendDeliveryAckEmitsDeliveredAck() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let noiseKey = Data((128..<160).map(UInt8.init)) let fullPeerID = PeerID(hexData: noiseKey) let relationship = makeRelationship( peerNoisePublicKey: noiseKey, peerNostrPublicKey: recipient.npub, peerNickname: "Eve" ) let probe = NostrTransportProbe() let transport = NostrTransport( keychain: keychain, idBridge: idBridge, dependencies: makeDependencies( favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, favoriteStatusForPeerID: { _ in nil }, currentIdentity: { sender }, registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), sendEvent: probe.record(event:), scheduleAfter: { delay, action in probe.enqueueScheduledAction(delay: delay, action: action) } ) ) transport.senderPeerID = PeerID(str: "0123456789abcdef") transport.sendDeliveryAck(for: "ack-1", to: fullPeerID) let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5) #expect(didSend) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) #expect(result.payload.type == .delivered) #expect(String(data: result.payload.data, encoding: .utf8) == "ack-1") #expect(result.packet.recipientID == fullPeerID.toShort().routingData) } @Test("Geohash private message registers pending gift wrap") @MainActor func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let probe = NostrTransportProbe() let transport = NostrTransport( keychain: keychain, idBridge: idBridge, dependencies: makeDependencies( currentIdentity: { sender }, registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), sendEvent: probe.record(event:), scheduleAfter: { delay, action in probe.enqueueScheduledAction(delay: delay, action: action) } ) ) transport.senderPeerID = PeerID(str: "0123456789abcdef") transport.sendPrivateMessageGeohash( content: "geo hello", toRecipientHex: recipient.publicKeyHex, from: sender, messageID: "geo-1" ) let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5) #expect(didSend) let event = probe.sentEvents[0] let result = try decodeEmbeddedPayload(from: event, recipient: recipient) let privateMessage = try decodePrivateMessage(from: result.payload) #expect(privateMessage.messageID == "geo-1") #expect(privateMessage.content == "geo hello") #expect(result.packet.recipientID == nil) #expect(probe.pendingGiftWrapIDs == [event.id]) } @Test("Read receipt queue sends in order and waits for scheduler") @MainActor func readReceiptQueueThrottlesSequentially() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let noiseKey = Data((160..<192).map(UInt8.init)) let fullPeerID = PeerID(hexData: noiseKey) let relationship = makeRelationship( peerNoisePublicKey: noiseKey, peerNostrPublicKey: recipient.npub, peerNickname: "Frank" ) let probe = NostrTransportProbe() let transport = NostrTransport( keychain: keychain, idBridge: idBridge, dependencies: makeDependencies( favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, favoriteStatusForPeerID: { _ in nil }, currentIdentity: { sender }, registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), sendEvent: probe.record(event:), scheduleAfter: { delay, action in probe.enqueueScheduledAction(delay: delay, action: action) } ) ) transport.senderPeerID = PeerID(str: "0123456789abcdef") let first = ReadReceipt(originalMessageID: "read-1", readerID: transport.myPeerID, readerNickname: "Me") let second = ReadReceipt(originalMessageID: "read-2", readerID: transport.myPeerID, readerNickname: "Me") transport.sendReadReceipt(first, to: fullPeerID) transport.sendReadReceipt(second, to: fullPeerID) let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 1.5) try #require(sentFirst, "Expected first queued read receipt event") let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: 1.5) try #require(scheduledThrottle, "Expected queued throttle action after first read receipt") let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event") let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload #expect(firstPayload.type == .readReceipt) #expect(String(data: firstPayload.data, encoding: .utf8) == "read-1") try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt") let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 1.5) try #require(sentSecond, "Expected second read receipt after running throttle action") let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event") let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload #expect(secondPayload.type == .readReceipt) #expect(String(data: secondPayload.data, encoding: .utf8) == "read-2") } @Test("Concurrent read receipt enqueue does not crash") @MainActor func concurrentReadReceiptEnqueue() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) let transport = NostrTransport(keychain: keychain, idBridge: idBridge) let iterations = 100 await withTaskGroup(of: Void.self) { group in for i in 0.. [Data: FavoriteRelationship] = { [:] }, favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil }, favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil }, currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil }, registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in }, sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in }, scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in } ) -> NostrTransport.Dependencies { NostrTransport.Dependencies( notificationCenter: notificationCenter, loadFavorites: loadFavorites, favoriteStatusForNoiseKey: favoriteStatusForNoiseKey, favoriteStatusForPeerID: favoriteStatusForPeerID, currentIdentity: currentIdentity, registerPendingGiftWrap: registerPendingGiftWrap, sendEvent: sendEvent, scheduleAfter: scheduleAfter ) } private func makeRelationship( peerNoisePublicKey: Data, peerNostrPublicKey: String?, peerNickname: String ) -> FavoriteRelationship { FavoriteRelationship( peerNoisePublicKey: peerNoisePublicKey, peerNostrPublicKey: peerNostrPublicKey, peerNickname: peerNickname, isFavorite: true, theyFavoritedUs: true, favoritedAt: Date(timeIntervalSince1970: 1), lastUpdated: Date(timeIntervalSince1970: 2) ) } private func decodeEmbeddedPayload( from event: NostrEvent, recipient: NostrIdentity ) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) { let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( giftWrap: event, recipientIdentity: recipient ) guard content.hasPrefix("bitchat1:") else { throw NostrTransportTestError.invalidEmbeddedContent } let encoded = String(content.dropFirst("bitchat1:".count)) guard let packetData = base64URLDecode(encoded), let packet = BitchatPacket.from(packetData), let payload = NoisePayload.decode(packet.payload) else { throw NostrTransportTestError.invalidPacket } return (packet, payload, senderPubkey) } private func decodePrivateMessage(from payload: NoisePayload) throws -> PrivateMessagePacket { guard payload.type == .privateMessage, let message = PrivateMessagePacket.decode(from: payload.data) else { throw NostrTransportTestError.invalidPrivateMessage } return message } } private enum NostrTransportTestError: Error { case invalidEmbeddedContent case invalidPacket case invalidPrivateMessage } private func base64URLDecode(_ string: String) -> Data? { var candidate = string let padding = (4 - (candidate.count % 4)) % 4 if padding > 0 { candidate += String(repeating: "=", count: padding) } candidate = candidate .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") return Data(base64Encoded: candidate) } private final class NostrTransportProbe: @unchecked Sendable { private let lock = NSLock() private var sentEventsStorage: [NostrEvent] = [] private var pendingGiftWrapIDsStorage: [String] = [] private var scheduledActionsStorage: [(@Sendable () -> Void)] = [] var sentEvents: [NostrEvent] { lock.lock() defer { lock.unlock() } return sentEventsStorage } var pendingGiftWrapIDs: [String] { lock.lock() defer { lock.unlock() } return pendingGiftWrapIDsStorage } var scheduledActionCount: Int { lock.lock() defer { lock.unlock() } return scheduledActionsStorage.count } func record(event: NostrEvent) { lock.lock() sentEventsStorage.append(event) lock.unlock() } func recordPendingGiftWrap(id: String) { lock.lock() pendingGiftWrapIDsStorage.append(id) lock.unlock() } func enqueueScheduledAction(delay: TimeInterval, action: @escaping @Sendable () -> Void) { _ = delay lock.lock() scheduledActionsStorage.append(action) lock.unlock() } @discardableResult func runNextScheduledAction() -> Bool { let action: (@Sendable () -> Void)? lock.lock() action = scheduledActionsStorage.isEmpty ? nil : scheduledActionsStorage.removeFirst() lock.unlock() guard let action else { return false } action() return true } } ================================================ FILE: bitchatTests/Services/NotificationServiceTests.swift ================================================ import XCTest import UserNotifications @testable import bitchat final class NotificationServiceTests: XCTestCase { func test_requestAuthorization_skipsWhenRunningTests() { let authorizer = RecordingNotificationAuthorizer() let service = NotificationService( isRunningTestsProvider: { true }, authorizer: authorizer, requestDeliverer: RecordingNotificationRequestDeliverer() ) service.requestAuthorization() XCTAssertEqual(authorizer.requestCallCount, 0) } func test_requestAuthorization_requestsAlertSoundAndBadgePermissions() { let authorizer = RecordingNotificationAuthorizer() let service = NotificationService( isRunningTestsProvider: { false }, authorizer: authorizer, requestDeliverer: RecordingNotificationRequestDeliverer() ) service.requestAuthorization() XCTAssertEqual(authorizer.requestCallCount, 1) XCTAssertEqual(authorizer.lastOptions, [.alert, .sound, .badge]) } func test_sendLocalNotification_buildsImmediateRequestWithUserInfo() { let deliverer = RecordingNotificationRequestDeliverer() let service = NotificationService( isRunningTestsProvider: { false }, authorizer: RecordingNotificationAuthorizer(), requestDeliverer: deliverer ) service.sendLocalNotification( title: "Hello", body: "World", identifier: "custom-id", userInfo: ["peerID": "abcd"], interruptionLevel: .timeSensitive ) let request = deliverer.requests.singleValue XCTAssertEqual(request?.identifier, "custom-id") XCTAssertEqual(request?.content.title, "Hello") XCTAssertEqual(request?.content.body, "World") XCTAssertEqual(request?.content.userInfo["peerID"] as? String, "abcd") XCTAssertEqual(request?.content.interruptionLevel, .timeSensitive) XCTAssertNil(request?.trigger) } func test_sendPrivateMessageNotification_populatesPeerMetadata() { let deliverer = RecordingNotificationRequestDeliverer() let service = NotificationService( isRunningTestsProvider: { false }, authorizer: RecordingNotificationAuthorizer(), requestDeliverer: deliverer ) let peerID = PeerID(str: "deadbeefdeadbeef") service.sendPrivateMessageNotification(from: "Alice", message: "hi", peerID: peerID) let request = deliverer.requests.singleValue XCTAssertEqual(request?.content.title, "🔒 DM from Alice") XCTAssertEqual(request?.content.body, "hi") XCTAssertEqual(request?.content.userInfo["peerID"] as? String, peerID.id) XCTAssertEqual(request?.content.userInfo["senderName"] as? String, "Alice") } func test_wrapperNotifications_setExpectedIdentifiersAndDeepLinks() { let deliverer = RecordingNotificationRequestDeliverer() let service = NotificationService( isRunningTestsProvider: { false }, authorizer: RecordingNotificationAuthorizer(), requestDeliverer: deliverer ) service.sendGeohashActivityNotification(geohash: "87yv", bodyPreview: "Someone is here") service.sendNetworkAvailableNotification(peerCount: 2) XCTAssertEqual(deliverer.requests.count, 2) XCTAssertEqual(deliverer.requests[0].content.userInfo["deeplink"] as? String, "bitchat://geohash/87yv") XCTAssertTrue(deliverer.requests[0].identifier.hasPrefix("geo-activity-87yv-")) XCTAssertEqual(deliverer.requests[1].identifier, "network-available") XCTAssertEqual(deliverer.requests[1].content.interruptionLevel, .timeSensitive) XCTAssertEqual(deliverer.requests[1].content.body, "2 people around") } } private final class RecordingNotificationAuthorizer: NotificationAuthorizing { private(set) var requestCallCount = 0 private(set) var lastOptions: UNAuthorizationOptions? func requestAuthorization( options: UNAuthorizationOptions, completionHandler: @escaping (Bool, Error?) -> Void ) { requestCallCount += 1 lastOptions = options completionHandler(true, nil) } } private final class RecordingNotificationRequestDeliverer: NotificationRequestDelivering { private(set) var requests: [UNNotificationRequest] = [] func add(_ request: UNNotificationRequest) { requests.append(request) } } private extension Array { var singleValue: Element? { count == 1 ? self[0] : nil } } ================================================ FILE: bitchatTests/Services/PrivateChatManagerTests.swift ================================================ // // PrivateChatManagerTests.swift // bitchatTests // // Tests for PrivateChatManager read receipt and selection behavior. // import Testing import Foundation @testable import bitchat struct PrivateChatManagerTests { @Test @MainActor func startChat_setsSelectedAndClearsUnread() async { let transport = MockTransport() let manager = PrivateChatManager(meshService: transport) let peerID = PeerID(str: "00000000000000AA") manager.privateChats[peerID] = [ BitchatMessage( id: "pm-1", sender: "Peer", content: "Hi", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Me", senderPeerID: peerID ) ] manager.unreadMessages.insert(peerID) manager.startChat(with: peerID) #expect(manager.selectedPeer == peerID) #expect(!manager.unreadMessages.contains(peerID)) #expect(manager.privateChats[peerID] != nil) } @Test @MainActor func markAsRead_sendsReadReceiptViaRouter() async { let transport = MockTransport() let router = MessageRouter(transports: [transport]) let manager = PrivateChatManager(meshService: transport) manager.messageRouter = router let peerID = PeerID(str: "00000000000000BB") transport.reachablePeers.insert(peerID) manager.privateChats[peerID] = [ BitchatMessage( id: "pm-2", sender: "Peer", content: "Hi", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Me", senderPeerID: peerID ) ] manager.unreadMessages.insert(peerID) manager.markAsRead(from: peerID) try? await Task.sleep(nanoseconds: 100_000_000) #expect(transport.sentReadReceipts.count == 1) #expect(manager.sentReadReceipts.contains("pm-2")) #expect(!manager.unreadMessages.contains(peerID)) } @Test @MainActor func markAsRead_withoutRouterFallsBackToTransport() async { let transport = MockTransport() let manager = PrivateChatManager(meshService: transport) let peerID = PeerID(str: "00000000000000CC") manager.privateChats[peerID] = [ BitchatMessage( id: "pm-fallback", sender: "Peer", content: "Hi", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Me", senderPeerID: peerID ) ] manager.markAsRead(from: peerID) #expect(transport.sentReadReceipts.count == 1) #expect(transport.sentReadReceipts.first?.receipt.originalMessageID == "pm-fallback") } @Test @MainActor func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async { let transport = MockTransport() let manager = PrivateChatManager(meshService: transport) let identityManager = MockIdentityManager(MockKeychain()) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let unifiedPeerService = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identityManager) manager.unifiedPeerService = unifiedPeerService let peerID = PeerID(str: "0123456789abcdef") let noiseKey = Data((0..<32).map(UInt8.init)) let stablePeerID = PeerID(hexData: noiseKey) transport.updatePeerSnapshots([ TransportPeerSnapshot( peerID: peerID, nickname: "Alice", isConnected: true, noisePublicKey: noiseKey, lastSeen: Date() ) ]) try? await Task.sleep(nanoseconds: 50_000_000) manager.privateChats[stablePeerID] = [ BitchatMessage( id: "stable-msg", sender: "Alice", content: "Hello from stable", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Me", senderPeerID: stablePeerID ) ] manager.unreadMessages.insert(stablePeerID) let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "Alice", persistedReadReceipts: []) #expect(hadUnread) #expect(manager.privateChats[stablePeerID] == nil) #expect(manager.privateChats[peerID]?.count == 1) #expect(manager.privateChats[peerID]?.first?.senderPeerID == peerID) #expect(manager.unreadMessages.contains(peerID)) } @Test @MainActor func consolidateMessages_movesTemporaryGeoDMHistoryByNickname() async { let transport = MockTransport() let manager = PrivateChatManager(meshService: transport) let peerID = PeerID(str: "0011223344556677") let tempPeerID = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000042") manager.privateChats[tempPeerID] = [ BitchatMessage( id: "geo-msg", sender: "Alice", content: "Geo hello", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Me", senderPeerID: tempPeerID ) ] manager.unreadMessages.insert(tempPeerID) let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "alice", persistedReadReceipts: []) #expect(hadUnread) #expect(manager.privateChats[tempPeerID] == nil) #expect(manager.privateChats[peerID]?.count == 1) #expect(manager.privateChats[peerID]?.first?.senderPeerID == peerID) #expect(manager.unreadMessages.contains(peerID)) #expect(!manager.unreadMessages.contains(tempPeerID)) } @Test @MainActor func syncReadReceiptsForSentMessages_onlyCopiesDeliveredAndRead() async { let transport = MockTransport() let manager = PrivateChatManager(meshService: transport) let peerID = PeerID(str: "00000000000000DD") manager.privateChats[peerID] = [ BitchatMessage( id: "sent-read", sender: "Me", content: "One", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Peer", senderPeerID: transport.myPeerID, deliveryStatus: .read(by: "Peer", at: Date()) ), BitchatMessage( id: "sent-delivered", sender: "Me", content: "Two", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Peer", senderPeerID: transport.myPeerID, deliveryStatus: .delivered(to: "Peer", at: Date()) ), BitchatMessage( id: "sent-failed", sender: "Me", content: "Three", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Peer", senderPeerID: transport.myPeerID, deliveryStatus: .failed(reason: "nope") ) ] var externalReceipts = Set() manager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: "Me", externalReceipts: &externalReceipts) #expect(externalReceipts == Set(["sent-read", "sent-delivered"])) #expect(manager.sentReadReceipts == Set(["sent-read", "sent-delivered"])) } @Test @MainActor func sanitizeChat_sortsChronologicallyAndKeepsLatestDuplicate() async { let transport = MockTransport() let manager = PrivateChatManager(meshService: transport) let peerID = PeerID(str: "00000000000000EE") let base = Date(timeIntervalSince1970: 10) manager.privateChats[peerID] = [ BitchatMessage( id: "same", sender: "Peer", content: "Older", timestamp: base.addingTimeInterval(10), isRelay: false, isPrivate: true, recipientNickname: "Me", senderPeerID: peerID ), BitchatMessage( id: "first", sender: "Peer", content: "First", timestamp: base, isRelay: false, isPrivate: true, recipientNickname: "Me", senderPeerID: peerID ), BitchatMessage( id: "same", sender: "Peer", content: "Newest", timestamp: base.addingTimeInterval(20), isRelay: false, isPrivate: true, recipientNickname: "Me", senderPeerID: peerID ) ] manager.sanitizeChat(for: peerID) #expect(manager.privateChats[peerID]?.map(\.id) == ["first", "same"]) #expect(manager.privateChats[peerID]?.last?.content == "Newest") } } ================================================ FILE: bitchatTests/Services/RelayControllerTests.swift ================================================ // // RelayControllerTests.swift // bitchatTests // // Tests for relay decision logic. // import Testing import Foundation @testable import bitchat struct RelayControllerTests { @Test func ttlOne_doesNotRelay() async { let decision = RelayController.decide( ttl: 1, senderIsSelf: false, isEncrypted: false, isDirectedEncrypted: false, isFragment: false, isDirectedFragment: false, isHandshake: false, isAnnounce: false, degree: 0, highDegreeThreshold: TransportConfig.bleHighDegreeThreshold ) #expect(!decision.shouldRelay) #expect(decision.newTTL == 1) } @Test func handshake_alwaysRelaysWithTTLDecrement() async { let decision = RelayController.decide( ttl: 3, senderIsSelf: false, isEncrypted: false, isDirectedEncrypted: false, isFragment: false, isDirectedFragment: false, isHandshake: true, isAnnounce: false, degree: 3, highDegreeThreshold: TransportConfig.bleHighDegreeThreshold ) #expect(decision.shouldRelay) #expect(decision.newTTL == 2) #expect(decision.delayMs >= 10 && decision.delayMs <= 35) } @Test func fragment_relaysWithFragmentCap() async { let decision = RelayController.decide( ttl: 10, senderIsSelf: false, isEncrypted: false, isDirectedEncrypted: false, isFragment: true, isDirectedFragment: false, isHandshake: false, isAnnounce: false, degree: 3, highDegreeThreshold: TransportConfig.bleHighDegreeThreshold ) let ttlCap = min(UInt8(10), TransportConfig.bleFragmentRelayTtlCap) let expected = ttlCap &- 1 #expect(decision.shouldRelay) #expect(decision.newTTL == expected) #expect(decision.delayMs >= TransportConfig.bleFragmentRelayMinDelayMs) #expect(decision.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs) } @Test func denseGraph_capsTTL() async { let decision = RelayController.decide( ttl: 10, senderIsSelf: false, isEncrypted: false, isDirectedEncrypted: false, isFragment: false, isDirectedFragment: false, isHandshake: false, isAnnounce: false, degree: TransportConfig.bleHighDegreeThreshold, highDegreeThreshold: TransportConfig.bleHighDegreeThreshold ) #expect(decision.shouldRelay) #expect(decision.newTTL == 4) } } ================================================ FILE: bitchatTests/Services/SecureIdentityStateManagerTests.swift ================================================ import Foundation import XCTest @testable import bitchat final class SecureIdentityStateManagerTests: XCTestCase { func test_upsertCryptographicIdentity_withoutClaimedNicknameDoesNotCreateSocialIdentity() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "aa", count: 32) let peerID = PeerID(str: String(fingerprint.prefix(16))) manager.upsertCryptographicIdentity( fingerprint: fingerprint, noisePublicKey: Data(repeating: 0x11, count: 32), signingPublicKey: Data(repeating: 0x22, count: 32), claimedNickname: nil ) let inserted = await waitUntil { manager.getCryptoIdentitiesByPeerIDPrefix(peerID).count == 1 } XCTAssertTrue(inserted) XCTAssertNil(manager.getSocialIdentity(for: fingerprint)) } func test_upsertCryptographicIdentity_updatesExistingKeyAndPreservesSigningKey() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "ab", count: 32) let peerID = PeerID(str: String(fingerprint.prefix(16))) let originalNoiseKey = Data(repeating: 0x11, count: 32) let updatedNoiseKey = Data(repeating: 0x33, count: 32) let signingKey = Data(repeating: 0x22, count: 32) manager.upsertCryptographicIdentity( fingerprint: fingerprint, noisePublicKey: originalNoiseKey, signingPublicKey: signingKey, claimedNickname: nil ) _ = await waitUntil { manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.publicKey == originalNoiseKey } manager.upsertCryptographicIdentity( fingerprint: fingerprint, noisePublicKey: updatedNoiseKey, signingPublicKey: nil, claimedNickname: nil ) let updated = await waitUntil { guard let identity = manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first else { return false } return identity.publicKey == updatedNoiseKey && identity.signingPublicKey == signingKey } XCTAssertTrue(updated) } func test_upsertCryptographicIdentity_tracksByPeerIDPrefixAndClaimedNickname() async { let manager = SecureIdentityStateManager(MockKeychain()) let noisePublicKey = Data(repeating: 0x11, count: 32) let signingPublicKey = Data(repeating: 0x22, count: 32) let fingerprint = noisePublicKey.sha256Fingerprint() manager.upsertCryptographicIdentity( fingerprint: fingerprint, noisePublicKey: noisePublicKey, signingPublicKey: signingPublicKey, claimedNickname: "Alice" ) let socialIdentityLoaded = await waitUntil { manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice" } XCTAssertTrue(socialIdentityLoaded) let matches = manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(publicKey: noisePublicKey)) XCTAssertEqual(matches.count, 1) XCTAssertEqual(matches.first?.fingerprint, fingerprint) XCTAssertEqual(matches.first?.publicKey, noisePublicKey) XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey) } func test_setBlocked_clearsFavoriteState() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "ab", count: 32) manager.setFavorite(fingerprint, isFavorite: true) let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) } XCTAssertTrue(favoriteSet) manager.setBlocked(fingerprint, isBlocked: true) let blockedSet = await waitUntil { manager.isBlocked(fingerprint: fingerprint) } XCTAssertTrue(blockedSet) XCTAssertFalse(manager.isFavorite(fingerprint: fingerprint)) XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown") } func test_isBlocked_unknownFingerprintReturnsFalse() { let manager = SecureIdentityStateManager(MockKeychain()) XCTAssertFalse(manager.isBlocked(fingerprint: String(repeating: "ff", count: 32))) } func test_setVerified_updatesTrustLevelAndVerifiedSet() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "cd", count: 32) manager.setFavorite(fingerprint, isFavorite: false) _ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil } manager.setVerified(fingerprint: fingerprint, verified: true) let verifiedSet = await waitUntil { manager.isVerified(fingerprint: fingerprint) } XCTAssertTrue(verifiedSet) XCTAssertTrue(manager.getVerifiedFingerprints().contains(fingerprint)) XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.trustLevel, .verified) } func test_forceSave_persistsFavoriteStateAcrossReinit() async { let keychain = MockKeychain() let manager = SecureIdentityStateManager(keychain) let fingerprint = String(repeating: "ef", count: 32) manager.setFavorite(fingerprint, isFavorite: true) let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) } XCTAssertTrue(favoriteSet) manager.forceSave() let reloaded = SecureIdentityStateManager(keychain) XCTAssertTrue(reloaded.isFavorite(fingerprint: fingerprint)) } func test_updateSocialIdentity_reindexesClaimedNickname() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "34", count: 32) manager.updateSocialIdentity( SocialIdentity( fingerprint: fingerprint, localPetname: nil, claimedNickname: "Alice", trustLevel: .unknown, isFavorite: false, isBlocked: false, notes: nil ) ) let initialIndexed = await waitUntil { manager.debugNicknameIndex["Alice"]?.contains(fingerprint) == true } XCTAssertTrue(initialIndexed) manager.updateSocialIdentity( SocialIdentity( fingerprint: fingerprint, localPetname: "Friend", claimedNickname: "Bob", trustLevel: .trusted, isFavorite: true, isBlocked: false, notes: "updated" ) ) let reindexed = await waitUntil { manager.debugNicknameIndex["Alice"]?.contains(fingerprint) != true && manager.debugNicknameIndex["Bob"]?.contains(fingerprint) == true } XCTAssertTrue(reindexed) XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Bob") } func test_upsertCryptographicIdentity_sameClaimedNicknamePreservesExistingSocialIdentity() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "35", count: 32) manager.updateSocialIdentity( SocialIdentity( fingerprint: fingerprint, localPetname: "Pal", claimedNickname: "Alice", trustLevel: .trusted, isFavorite: true, isBlocked: false, notes: "keep me" ) ) _ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil } manager.upsertCryptographicIdentity( fingerprint: fingerprint, noisePublicKey: Data(repeating: 0x11, count: 32), signingPublicKey: Data(repeating: 0x22, count: 32), claimedNickname: "Alice" ) let inserted = await waitUntil { manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: String(fingerprint.prefix(16)))).count == 1 } XCTAssertTrue(inserted) XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.localPetname, "Pal") XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.notes, "keep me") XCTAssertTrue(manager.getSocialIdentity(for: fingerprint)?.isFavorite == true) } func test_getFavorites_returnsOnlyFavoritedFingerprints() async { let manager = SecureIdentityStateManager(MockKeychain()) let favoriteOne = String(repeating: "45", count: 32) let favoriteTwo = String(repeating: "56", count: 32) let other = String(repeating: "67", count: 32) manager.setFavorite(favoriteOne, isFavorite: true) manager.setFavorite(favoriteTwo, isFavorite: true) manager.setFavorite(other, isFavorite: false) let favoritesLoaded = await waitUntil { manager.getFavorites() == Set([favoriteOne, favoriteTwo]) } XCTAssertTrue(favoritesLoaded) } func test_setFavorite_existingIdentityCanBeClearedWithoutChangingNickname() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "68", count: 32) manager.updateSocialIdentity( SocialIdentity( fingerprint: fingerprint, localPetname: nil, claimedNickname: "Alice", trustLevel: .trusted, isFavorite: false, isBlocked: false, notes: nil ) ) _ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil } manager.setFavorite(fingerprint, isFavorite: true) _ = await waitUntil { manager.isFavorite(fingerprint: fingerprint) } manager.setFavorite(fingerprint, isFavorite: false) let cleared = await waitUntil { !manager.isFavorite(fingerprint: fingerprint) && manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice" && manager.getSocialIdentity(for: fingerprint)?.trustLevel == .trusted } XCTAssertTrue(cleared) } func test_setBlocked_createsIdentityAndCanLaterUnblock() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "78", count: 32) manager.setBlocked(fingerprint, isBlocked: true) let blocked = await waitUntil { manager.isBlocked(fingerprint: fingerprint) } XCTAssertTrue(blocked) XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown") manager.setBlocked(fingerprint, isBlocked: false) let unblocked = await waitUntil { !manager.isBlocked(fingerprint: fingerprint) } XCTAssertTrue(unblocked) } func test_setVerified_false_downgradesTrustLevelToCasual() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "89", count: 32) manager.updateSocialIdentity( SocialIdentity( fingerprint: fingerprint, localPetname: nil, claimedNickname: "Verifier", trustLevel: .trusted, isFavorite: false, isBlocked: false, notes: nil ) ) _ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil } manager.setVerified(fingerprint: fingerprint, verified: true) _ = await waitUntil { manager.isVerified(fingerprint: fingerprint) } manager.setVerified(fingerprint: fingerprint, verified: false) let downgraded = await waitUntil { !manager.isVerified(fingerprint: fingerprint) && manager.getSocialIdentity(for: fingerprint)?.trustLevel == .casual } XCTAssertTrue(downgraded) } func test_ephemeralSessionLifecycle_tracksHandshakeProgressAndLastInteraction() async { let manager = SecureIdentityStateManager(MockKeychain()) let peerID = PeerID(str: "1234567890abcdef") let fingerprint = String(repeating: "90", count: 32) manager.registerEphemeralSession(peerID: peerID, handshakeState: .initiated) let registered = await waitUntil { if case .initiated? = manager.debugEphemeralSession(for: peerID)?.handshakeState { return true } return false } XCTAssertTrue(registered) manager.updateHandshakeState(peerID: peerID, state: .inProgress) let progressed = await waitUntil { if case .inProgress? = manager.debugEphemeralSession(for: peerID)?.handshakeState { return true } return false } XCTAssertTrue(progressed) manager.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprint)) let completed = await waitUntil { if case .completed(let completedFingerprint)? = manager.debugEphemeralSession(for: peerID)?.handshakeState { return completedFingerprint == fingerprint && manager.debugLastInteraction(for: fingerprint) != nil } return false } XCTAssertTrue(completed) manager.removeEphemeralSession(peerID: peerID) let removed = await waitUntil { manager.debugEphemeralSession(for: peerID) == nil } XCTAssertTrue(removed) } func test_setNostrBlocked_normalizesToLowercaseAndPersists() async { let keychain = MockKeychain() let manager = SecureIdentityStateManager(keychain) let pubkey = "ABCDEF1234" manager.setNostrBlocked(pubkey, isBlocked: true) let nostrBlocked = await waitUntil { manager.isNostrBlocked(pubkeyHexLowercased: pubkey.lowercased()) } XCTAssertTrue(nostrBlocked) manager.forceSave() let reloaded = SecureIdentityStateManager(keychain) XCTAssertEqual(reloaded.getBlockedNostrPubkeys(), Set([pubkey.lowercased()])) XCTAssertTrue(reloaded.isNostrBlocked(pubkeyHexLowercased: pubkey)) } func test_setNostrBlocked_falseRemovesExistingKey() async { let manager = SecureIdentityStateManager(MockKeychain()) let pubkey = "ABCDEF1234" manager.setNostrBlocked(pubkey, isBlocked: true) _ = await waitUntil { manager.isNostrBlocked(pubkeyHexLowercased: pubkey) } manager.setNostrBlocked(pubkey, isBlocked: false) let cleared = await waitUntil { !manager.isNostrBlocked(pubkeyHexLowercased: pubkey) && manager.getBlockedNostrPubkeys().isEmpty } XCTAssertTrue(cleared) } func test_corruptPersistedCache_fallsBackToEmptyState() { let keychain = MockKeychain() _ = keychain.saveIdentityKey(Data(repeating: 0x01, count: 32), forKey: "identityCacheEncryptionKey") _ = keychain.saveIdentityKey(Data([0xFF, 0x00, 0xAA]), forKey: "bitchat.identityCache.v2") let manager = SecureIdentityStateManager(keychain) XCTAssertTrue(manager.getFavorites().isEmpty) XCTAssertTrue(manager.getVerifiedFingerprints().isEmpty) XCTAssertTrue(manager.getBlockedNostrPubkeys().isEmpty) } func test_clearAllIdentityData_removesCachedState() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "12", count: 32) manager.setFavorite(fingerprint, isFavorite: true) manager.setVerified(fingerprint: fingerprint, verified: true) manager.setNostrBlocked("ABCD", isBlocked: true) let primed = await waitUntil { manager.isFavorite(fingerprint: fingerprint) && manager.isVerified(fingerprint: fingerprint) } XCTAssertTrue(primed) manager.clearAllIdentityData() let cleared = await waitUntil { !manager.isFavorite(fingerprint: fingerprint) && !manager.isVerified(fingerprint: fingerprint) && manager.getBlockedNostrPubkeys().isEmpty } XCTAssertTrue(cleared) } func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async { let keychain = FailingCacheSaveKeychain() let manager = SecureIdentityStateManager(keychain) let fingerprint = String(repeating: "de", count: 32) manager.setFavorite(fingerprint, isFavorite: true) let primed = await waitUntil { manager.isFavorite(fingerprint: fingerprint) } XCTAssertTrue(primed) manager.forceSave() let reloaded = SecureIdentityStateManager(keychain) XCTAssertFalse(reloaded.isFavorite(fingerprint: fingerprint)) } private func waitUntil( timeout: TimeInterval = 1.0, condition: @escaping () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if condition() { return true } try? await Task.sleep(nanoseconds: 10_000_000) } return condition() } } private final class FailingCacheSaveKeychain: KeychainManagerProtocol { private var storage: [String: Data] = [:] private var serviceStorage: [String: [String: Data]] = [:] func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { if key == "bitchat.identityCache.v2" { return false } storage[key] = keyData return true } func getIdentityKey(forKey key: String) -> Data? { storage[key] } func deleteIdentityKey(forKey key: String) -> Bool { storage.removeValue(forKey: key) return true } func deleteAllKeychainData() -> Bool { storage.removeAll() serviceStorage.removeAll() return true } func secureClear(_ data: inout Data) { data = Data() } func secureClear(_ string: inout String) { string = "" } func verifyIdentityKeyExists() -> Bool { storage["identity_noiseStaticKey"] != nil } func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { if let data = storage[key] { return .success(data) } return .itemNotFound } func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { if saveIdentityKey(keyData, forKey: key) { return .success } return .otherError(OSStatus(-1)) } func save(key: String, data: Data, service: String, accessible: CFString?) { if serviceStorage[service] == nil { serviceStorage[service] = [:] } serviceStorage[service]?[key] = data } func load(key: String, service: String) -> Data? { serviceStorage[service]?[key] } func delete(key: String, service: String) { serviceStorage[service]?.removeValue(forKey: key) } } ================================================ FILE: bitchatTests/Services/TransferProgressManagerTests.swift ================================================ import Foundation import Combine import Testing @testable import bitchat @Suite("TransferProgressManager Tests") struct TransferProgressManagerTests { @Test("Start publishes started event and stores snapshot") @MainActor func startPublishesAndStoresSnapshot() async throws { let manager = TransferProgressManager() let transferID = "transfer-start" var cancellable: AnyCancellable? let recorder = EventRecorder() cancellable = manager.publisher.sink { event in if case .started(let id, let total) = event { recorder.append("started:\(id):\(total)") } } manager.start(id: transferID, totalFragments: 3) let didReceive = await TestHelpers.waitUntil({ recorder.values == ["started:\(transferID):3"] }, timeout: 0.5) #expect(didReceive) #expect(recorder.values == ["started:\(transferID):3"]) #expect(manager.snapshot(id: transferID)?.sent == 0) #expect(manager.snapshot(id: transferID)?.total == 3) _ = cancellable } @Test("Sending final fragment publishes update and completion then clears snapshot") @MainActor func recordFragmentSentPublishesProgressAndCompletion() async throws { let manager = TransferProgressManager() let transferID = "transfer-complete" var cancellable: AnyCancellable? let recorder = EventRecorder() cancellable = manager.publisher.sink { event in switch event { case .started(let id, let total): recorder.append("started:\(id):\(total)") case .updated(let id, let sent, let total): recorder.append("updated:\(id):\(sent):\(total)") case .completed(let id, let total): recorder.append("completed:\(id):\(total)") case .cancelled: break } } manager.start(id: transferID, totalFragments: 1) manager.recordFragmentSent(id: transferID) let didReceive = await TestHelpers.waitUntil({ recorder.values.count == 3 }, timeout: 0.5) #expect(didReceive) #expect(recorder.values == [ "started:\(transferID):1", "updated:\(transferID):1:1", "completed:\(transferID):1" ]) #expect(manager.snapshot(id: transferID) == nil) _ = cancellable } @Test("Cancel publishes cancelled event and clears state") @MainActor func cancelPublishesAndClearsState() async throws { let manager = TransferProgressManager() let transferID = "transfer-cancel" var cancellable: AnyCancellable? let recorder = EventRecorder() cancellable = manager.publisher.sink { event in switch event { case .started(let id, let total): recorder.append("started:\(id):\(total)") case .cancelled(let id, let sent, let total): recorder.append("cancelled:\(id):\(sent):\(total)") case .updated, .completed: break } } manager.start(id: transferID, totalFragments: 4) manager.recordFragmentSent(id: transferID) manager.cancel(id: transferID) let didReceive = await TestHelpers.waitUntil({ recorder.values.contains("started:\(transferID):4") && recorder.values.contains("cancelled:\(transferID):1:4") }, timeout: 0.5) #expect(didReceive) #expect(recorder.values.contains("started:\(transferID):4")) #expect(recorder.values.contains("cancelled:\(transferID):1:4")) #expect(manager.snapshot(id: transferID) == nil) _ = cancellable } } private final class EventRecorder: @unchecked Sendable { private let lock = NSLock() private var storage: [String] = [] var values: [String] { lock.lock() defer { lock.unlock() } return storage } func append(_ value: String) { lock.lock() storage.append(value) lock.unlock() } } ================================================ FILE: bitchatTests/Services/UnifiedPeerServiceTests.swift ================================================ // // UnifiedPeerServiceTests.swift // bitchatTests // // Tests for UnifiedPeerService fingerprint and block resolution. // import Testing import Foundation @testable import bitchat struct UnifiedPeerServiceTests { @Test @MainActor func getFingerprint_prefersMeshService() async { let transport = MockTransport() let identity = TestIdentityManager() let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity) let peerID = PeerID(str: "00000000000000CC") transport.peerFingerprints[peerID] = "fp-1" let fingerprint = service.getFingerprint(for: peerID) #expect(fingerprint == "fp-1") } @Test @MainActor func isBlocked_usesSocialIdentity() async { let transport = MockTransport() let identity = TestIdentityManager() let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity) let peerID = PeerID(str: "00000000000000DD") let fingerprint = "fp-blocked" transport.peerFingerprints[peerID] = fingerprint identity.setBlocked(fingerprint, isBlocked: true) #expect(service.isBlocked(peerID)) } } private final class TestIdentityManager: SecureIdentityStateManagerProtocol { private var socialIdentities: [String: SocialIdentity] = [:] private var favorites: Set = [] private var blockedNostr: Set = [] private var verified: Set = [] func forceSave() {} func getSocialIdentity(for fingerprint: String) -> SocialIdentity? { socialIdentities[fingerprint] } func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {} func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] { [] } func updateSocialIdentity(_ identity: SocialIdentity) { socialIdentities[identity.fingerprint] = identity } func getFavorites() -> Set { favorites } func setFavorite(_ fingerprint: String, isFavorite: Bool) { if isFavorite { favorites.insert(fingerprint) } else { favorites.remove(fingerprint) } } func isFavorite(fingerprint: String) -> Bool { favorites.contains(fingerprint) } func isBlocked(fingerprint: String) -> Bool { socialIdentities[fingerprint]?.isBlocked ?? false } func setBlocked(_ fingerprint: String, isBlocked: Bool) { var identity = socialIdentities[fingerprint] ?? SocialIdentity( fingerprint: fingerprint, localPetname: nil, claimedNickname: "", trustLevel: .unknown, isFavorite: false, isBlocked: false, notes: nil ) identity.isBlocked = isBlocked socialIdentities[fingerprint] = identity } func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { blockedNostr.contains(pubkeyHexLowercased) } func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { if isBlocked { blockedNostr.insert(pubkeyHexLowercased) } else { blockedNostr.remove(pubkeyHexLowercased) } } func getBlockedNostrPubkeys() -> Set { blockedNostr } func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {} func updateHandshakeState(peerID: PeerID, state: HandshakeState) {} func clearAllIdentityData() { socialIdentities.removeAll() favorites.removeAll() blockedNostr.removeAll() verified.removeAll() } func removeEphemeralSession(peerID: PeerID) {} func setVerified(fingerprint: String, verified: Bool) { if verified { self.verified.insert(fingerprint) } else { self.verified.remove(fingerprint) } } func isVerified(fingerprint: String) -> Bool { verified.contains(fingerprint) } func getVerifiedFingerprints() -> Set { verified } } ================================================ FILE: bitchatTests/Services/VerificationServiceTests.swift ================================================ import XCTest @testable import bitchat final class VerificationServiceTests: XCTestCase { func test_buildMyQRString_roundTripsSuccessfully() throws { let (service, noise) = makeService() let nickname = "alice-\(UUID().uuidString)" let npub = "npub1testvalue" let qrString = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: npub)) let parsed = try XCTUnwrap(service.verifyScannedQR(qrString)) XCTAssertEqual(parsed.nickname, nickname) XCTAssertEqual(parsed.npub, npub) XCTAssertEqual(parsed.noiseKeyHex, noise.getStaticPublicKeyData().hexEncodedString()) XCTAssertEqual(parsed.signKeyHex, noise.getSigningPublicKeyData().hexEncodedString()) } func test_buildMyQRString_returnsCachedValueForSameInputs() throws { let (service, _) = makeService() let nickname = "cache-\(UUID().uuidString)" let first = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: nil)) let second = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: nil)) XCTAssertEqual(first, second) } func test_verifyScannedQR_rejectsExpiredPayload() throws { let (service, noise) = makeService() let oldTimestamp = Int64(Date().addingTimeInterval(-3600).timeIntervalSince1970) let qrString = try makeSignedQR( noise: noise, nickname: "expired-\(UUID().uuidString)", npub: nil, ts: oldTimestamp ) XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60)) } func test_verifyScannedQR_rejectsTamperedSignature() throws { let (service, noise) = makeService() let badSignature = Data(repeating: 0xAA, count: 64) let qrString = try makeSignedQR( noise: noise, nickname: "tampered-\(UUID().uuidString)", npub: nil, ts: Int64(Date().timeIntervalSince1970), signatureOverride: badSignature ) XCTAssertNil(service.verifyScannedQR(qrString)) } func test_buildVerifyChallenge_roundTripsThroughNoisePayload() throws { let (service, _) = makeService() let noiseKeyHex = String(repeating: "ab", count: 32) let nonce = Data([0x01, 0x02, 0x03, 0x04]) let encoded = service.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonce) let payload = try XCTUnwrap(NoisePayload.decode(encoded)) let parsed = try XCTUnwrap(service.parseVerifyChallenge(payload.data)) XCTAssertEqual(payload.type, .verifyChallenge) XCTAssertEqual(parsed.noiseKeyHex, noiseKeyHex) XCTAssertEqual(parsed.nonceA, nonce) } func test_buildVerifyResponse_roundTripsAndVerifiesSignature() throws { let (service, noise) = makeService() let noiseKeyHex = String(repeating: "cd", count: 32) let nonce = Data([0x10, 0x20, 0x30, 0x40, 0x50]) let encoded = try XCTUnwrap(service.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonce)) let payload = try XCTUnwrap(NoisePayload.decode(encoded)) let parsed = try XCTUnwrap(service.parseVerifyResponse(payload.data)) XCTAssertEqual(payload.type, .verifyResponse) XCTAssertEqual(parsed.noiseKeyHex, noiseKeyHex) XCTAssertEqual(parsed.nonceA, nonce) XCTAssertTrue( service.verifyResponseSignature( noiseKeyHex: parsed.noiseKeyHex, nonceA: parsed.nonceA, signature: parsed.signature, signerPublicKeyHex: noise.getSigningPublicKeyData().hexEncodedString() ) ) XCTAssertFalse( service.verifyResponseSignature( noiseKeyHex: parsed.noiseKeyHex, nonceA: Data([0xFF]), signature: parsed.signature, signerPublicKeyHex: noise.getSigningPublicKeyData().hexEncodedString() ) ) } private func makeService() -> (VerificationService, NoiseEncryptionService) { let noise = NoiseEncryptionService(keychain: MockKeychain()) let service = VerificationService() service.configure(with: noise) return (service, noise) } private func makeSignedQR( noise: NoiseEncryptionService, nickname: String, npub: String?, ts: Int64, signatureOverride: Data? = nil ) throws -> String { var payload = VerificationService.VerificationQR( v: 1, noiseKeyHex: noise.getStaticPublicKeyData().hexEncodedString(), signKeyHex: noise.getSigningPublicKeyData().hexEncodedString(), npub: npub, nickname: nickname, ts: ts, nonceB64: Data((0..<16).map(UInt8.init)).base64EncodedString(), sigHex: "" ) let signature = try XCTUnwrap(signatureOverride ?? noise.signData(payload.canonicalBytes())) payload.sigHex = signature.hexEncodedString() return payload.toURLString() } } ================================================ FILE: bitchatTests/SubscriptionRateLimitTests.swift ================================================ // // SubscriptionRateLimitTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Testing import Foundation @testable import bitchat /// Tests for BCH-01-004 fix: Rate-limiting subscription-triggered announces /// to prevent device enumeration attacks struct SubscriptionRateLimitTests { @Test("Rate limit configuration values are sensible") func rateLimitConfigurationValues() { // Minimum interval should be at least 1 second to slow enumeration #expect(TransportConfig.bleSubscriptionRateLimitMinSeconds >= 1.0) // Backoff factor should be > 1 for exponential backoff #expect(TransportConfig.bleSubscriptionRateLimitBackoffFactor > 1.0) // Max backoff should be reasonable (not hours) #expect(TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds <= 60.0) #expect(TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds >= TransportConfig.bleSubscriptionRateLimitMinSeconds) // Window should be long enough to track repeated attempts #expect(TransportConfig.bleSubscriptionRateLimitWindowSeconds >= 30.0) // Max attempts before suppression should be > 1 to allow legitimate reconnects #expect(TransportConfig.bleSubscriptionRateLimitMaxAttempts >= 2) } @Test("Exponential backoff calculation is correct") func exponentialBackoffCalculation() { let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds let factor = TransportConfig.bleSubscriptionRateLimitBackoffFactor let maxBackoff = TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds // Simulate backoff progression var currentBackoff = minInterval var iterations = 0 let maxIterations = 10 while currentBackoff < maxBackoff && iterations < maxIterations { let nextBackoff = min(currentBackoff * factor, maxBackoff) #expect(nextBackoff >= currentBackoff, "Backoff should increase or stay at max") currentBackoff = nextBackoff iterations += 1 } // Should reach max within reasonable iterations #expect(iterations <= maxIterations, "Backoff should reach max within \(maxIterations) iterations") #expect(currentBackoff == maxBackoff, "Final backoff should equal max") } @Test("Rate limiting would significantly slow enumeration attacks") func rateLimitingSlowsEnumeration() { // Without rate limiting: ~120 devices/minute (0.5 seconds per device) // With rate limiting: minimum interval enforced let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds let devicesPerMinuteWithRateLimit = 60.0 / minInterval // Should be significantly slower than 120 devices/minute #expect(devicesPerMinuteWithRateLimit < 60, "Rate limiting should significantly slow enumeration") // With 2-second minimum interval, max ~30 devices/minute per connection // And with backoff, repeated attempts are even slower #expect(devicesPerMinuteWithRateLimit <= 30, "With 2s minimum, should be <=30/min") } @Test("Max attempts threshold prevents complete enumeration") func maxAttemptsThresholdPreventsEnumeration() { let maxAttempts = TransportConfig.bleSubscriptionRateLimitMaxAttempts // After max attempts within window, announces are suppressed entirely // This means an attacker gets at most maxAttempts announces per window #expect(maxAttempts >= 2, "Should allow at least 2 attempts for legitimate reconnects") #expect(maxAttempts <= 10, "Should cap attempts to prevent enumeration") // With 5 attempts max and 2s minimum interval, attacker gets limited info let maxAnnounces = maxAttempts #expect(maxAnnounces <= 10, "Max announces per window should be limited") } } ================================================ FILE: bitchatTests/Sync/RequestSyncManagerTests.swift ================================================ import XCTest @testable import bitchat final class RequestSyncManagerTests: XCTestCase { func test_isValidResponse_returnsFalseWhenPacketIsNotRSR() { let clock = MutableSyncClock(now: 1_000) let manager = RequestSyncManager(responseWindow: 30, now: { clock.now }) manager.registerRequest(to: PeerID(str: "aaaaaaaaaaaaaaaa")) XCTAssertFalse(manager.isValidResponse(from: PeerID(str: "aaaaaaaaaaaaaaaa"), isRSR: false)) } func test_isValidResponse_returnsFalseForUnsolicitedRSR() { let clock = MutableSyncClock(now: 1_000) let manager = RequestSyncManager(responseWindow: 30, now: { clock.now }) XCTAssertFalse(manager.isValidResponse(from: PeerID(str: "bbbbbbbbbbbbbbbb"), isRSR: true)) } func test_isValidResponse_acceptsRecentRequest() async { let clock = MutableSyncClock(now: 1_000) let manager = RequestSyncManager(responseWindow: 30, now: { clock.now }) let peerID = PeerID(str: "cccccccccccccccc") manager.registerRequest(to: peerID) let registered = await waitUntil { manager.debugPendingRequestCount == 1 } XCTAssertTrue(registered) clock.now = 1_020 XCTAssertTrue(manager.isValidResponse(from: peerID, isRSR: true)) } func test_cleanup_removesExpiredRequestsAndPreservesFreshOnes() async { let clock = MutableSyncClock(now: 1_000) let manager = RequestSyncManager(responseWindow: 30, now: { clock.now }) let expiredPeer = PeerID(str: "dddddddddddddddd") let freshPeer = PeerID(str: "eeeeeeeeeeeeeeee") manager.registerRequest(to: expiredPeer) _ = await waitUntil { manager.debugPendingRequestCount == 1 } clock.now = 1_010 manager.registerRequest(to: freshPeer) let bothRegistered = await waitUntil { manager.debugPendingRequestCount == 2 } XCTAssertTrue(bothRegistered) clock.now = 1_035 XCTAssertFalse(manager.isValidResponse(from: expiredPeer, isRSR: true)) XCTAssertTrue(manager.isValidResponse(from: freshPeer, isRSR: true)) manager.cleanup() let cleaned = await waitUntil { manager.debugPendingRequestCount == 1 } XCTAssertTrue(cleaned) XCTAssertFalse(manager.isValidResponse(from: expiredPeer, isRSR: true)) XCTAssertTrue(manager.isValidResponse(from: freshPeer, isRSR: true)) } private func waitUntil( timeout: TimeInterval = 1.0, condition: @escaping () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if condition() { return true } try? await Task.sleep(nanoseconds: 10_000_000) } return condition() } } private final class MutableSyncClock { var now: TimeInterval init(now: TimeInterval) { self.now = now } } ================================================ FILE: bitchatTests/TestUtilities/TestConstants.swift ================================================ // // TestConstants.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation @testable import bitchat struct TestConstants { static let defaultTimeout: TimeInterval = 5.0 static let shortTimeout: TimeInterval = 1.0 static let longTimeout: TimeInterval = 10.0 static let testNickname1 = "Alice" static let testNickname2 = "Bob" static let testNickname3 = "Charlie" static let testNickname4 = "David" static let testMessage1 = "Hello, World!" static let testMessage2 = "How are you?" static let testMessage3 = "This is a test message" static let testLongMessage = String(repeating: "This is a long message. ", count: 100) static let testSignature = Data(repeating: 0xAB, count: 64) } ================================================ FILE: bitchatTests/TestUtilities/TestHelpers.swift ================================================ // // TestHelpers.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation import CryptoKit @testable import bitchat final class TestHelpers { // MARK: - Key Generation static func generateTestKeyPair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) { let privateKey = Curve25519.KeyAgreement.PrivateKey() let publicKey = privateKey.publicKey return (privateKey, publicKey) } static func generateTestIdentity(peerID: String, nickname: String) -> (peerID: String, nickname: String, privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) { let (privateKey, publicKey) = generateTestKeyPair() return (peerID: peerID, nickname: nickname, privateKey: privateKey, publicKey: publicKey) } // MARK: - Message Creation static func createTestMessage( content: String = TestConstants.testMessage1, sender: String = TestConstants.testNickname1, senderPeerID: PeerID = PeerID(str: UUID().uuidString), isPrivate: Bool = false, recipientNickname: String? = nil, mentions: [String]? = nil ) -> BitchatMessage { return BitchatMessage( id: UUID().uuidString, sender: sender, content: content, timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: isPrivate, recipientNickname: recipientNickname, senderPeerID: senderPeerID, mentions: mentions ) } static func createTestPacket( type: UInt8 = 0x01, senderID: PeerID = PeerID(str: UUID().uuidString), recipientID: PeerID? = nil, payload: Data = "test payload".data(using: .utf8)!, signature: Data? = nil, ttl: UInt8 = 3 ) -> BitchatPacket { return BitchatPacket( type: type, senderID: senderID.id.data(using: .utf8)!, recipientID: recipientID?.id.data(using: .utf8), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: signature, ttl: ttl ) } // MARK: - Data Generation static func generateRandomData(length: Int) -> Data { var data = Data(count: length) _ = data.withUnsafeMutableBytes { bytes in SecRandomCopyBytes(kSecRandomDefault, length, bytes.baseAddress!) } return data } static func generateTestPeerID() -> String { return "PEER" + UUID().uuidString.prefix(8) } // MARK: - Async Helpers static func waitFor(_ condition: @escaping () -> Bool, timeout: TimeInterval = TestConstants.defaultTimeout) async throws { let start = Date() while !condition() { if Date().timeIntervalSince(start) > timeout { throw TestError.timeout } try await sleep(0.01) } } @MainActor static func waitUntil( _ condition: @escaping () -> Bool, timeout: TimeInterval = TestConstants.defaultTimeout, pollInterval: TimeInterval = 0.01 ) async -> Bool { let start = Date() while !condition() { if Date().timeIntervalSince(start) > timeout { return condition() } try? await sleep(pollInterval) } return true } static func expectAsync( timeout: TimeInterval = TestConstants.defaultTimeout, operation: @escaping () async throws -> T ) async throws -> T { return try await withThrowingTaskGroup(of: T.self) { group in group.addTask { return try await operation() } group.addTask { try await sleep(1) throw TestError.timeout } let result = try await group.next()! group.cancelAll() return result } } } enum TestError: Error { case timeout case unexpectedValue case testFailure(String) } func sleep(_ seconds: TimeInterval) async throws { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) } ================================================ FILE: bitchatTests/Utils/HexStringTests.swift ================================================ // // HexStringTests.swift // bitchatTests // // Tests for Data(hexString:) hex parsing // import Testing import Foundation @testable import bitchat struct HexStringTests { // MARK: - Valid Hex Strings @Test func validHexString() { let data = Data(hexString: "0102030405") #expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05])) } @Test func validHexStringUppercase() { let data = Data(hexString: "AABBCCDD") #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) } @Test func validHexStringMixedCase() { let data = Data(hexString: "aAbBcCdD") #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) } @Test func validHexStringWith0xPrefix() { let data = Data(hexString: "0x0102030405") #expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05])) } @Test func validHexStringWith0XPrefix() { let data = Data(hexString: "0XAABBCCDD") #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) } @Test func validHexStringWithWhitespace() { let data = Data(hexString: " 0102030405 ") #expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05])) } @Test func validHexStringWith0xPrefixAndWhitespace() { let data = Data(hexString: " 0x0102030405 ") #expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05])) } @Test func emptyHexString() { let data = Data(hexString: "") #expect(data == Data()) } @Test func emptyHexStringWithWhitespace() { let data = Data(hexString: " ") #expect(data == Data()) } @Test func emptyHexStringWith0xPrefix() { let data = Data(hexString: "0x") #expect(data == Data()) } // MARK: - Invalid Hex Strings @Test func oddLengthHexStringReturnsNil() { let data = Data(hexString: "012") #expect(data == nil) } @Test func oddLengthHexStringWith0xPrefixReturnsNil() { let data = Data(hexString: "0x012") #expect(data == nil) } @Test func invalidCharactersReturnNil() { let data = Data(hexString: "GHIJ") #expect(data == nil) } @Test func mixedValidAndInvalidCharactersReturnNil() { let data = Data(hexString: "01GH") #expect(data == nil) } @Test func specialCharactersReturnNil() { let data = Data(hexString: "01-02") #expect(data == nil) } // MARK: - Round Trip Tests @Test func roundTripConversion() { let original = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) let hexString = original.hexEncodedString() let roundTripped = Data(hexString: hexString) #expect(roundTripped == original) } @Test func roundTripConversionWith0xPrefix() { let original = Data([0xDE, 0xAD, 0xBE, 0xEF]) let hexString = "0x" + original.hexEncodedString() let roundTripped = Data(hexString: hexString) #expect(roundTripped == original) } } ================================================ FILE: bitchatTests/Utils/PeerIDTests.swift ================================================ // // PeerIDTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Testing import Foundation @testable import bitchat struct PeerIDTests { private let hex16 = "0011223344556677" private let hex64 = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" private let encoder: JSONEncoder = { var encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] return encoder }() // MARK: - Empty prefix @Test func empty_prefix_with16() { let peerID = PeerID(str: hex16) #expect(peerID.id == hex16) #expect(peerID.bare == hex16) #expect(peerID.prefix == .empty) } @Test func empty_prefix_with64() { let peerID = PeerID(str: hex64) #expect(peerID.id == hex64) #expect(peerID.bare == hex64) #expect(peerID.prefix == .empty) } // MARK: - Mesh prefix @Test func mesh_prefix_with16() { let str = "mesh:" + hex16 let peerID = PeerID(str: str) #expect(peerID.id == str) #expect(peerID.bare == hex16) #expect(peerID.prefix == .mesh) } @Test func mesh_prefix_with64() { let str = "mesh:" + hex64 let peerID = PeerID(str: str) #expect(peerID.id == str) #expect(peerID.bare == hex64) #expect(peerID.prefix == .mesh) } // MARK: - Name prefix @Test func name_prefix() { let str = "name:some_name" let peerID = PeerID(str: str) #expect(peerID.id == str) #expect(peerID.bare == "some_name") #expect(peerID.prefix == .name) } // MARK: - Noise prefix @Test func noise_prefix_with16() { let str = "noise:" + hex16 let peerID = PeerID(str: str) #expect(peerID.id == str) #expect(peerID.bare == hex16) #expect(peerID.prefix == .noise) } @Test func noise_prefix_with64() { let str = "noise:" + hex64 let peerID = PeerID(str: str) #expect(peerID.id == str) #expect(peerID.bare == hex64) #expect(peerID.prefix == .noise) } // MARK: - GeoDM prefix @Test func geoDM_prefix_with16() { let str = "nostr_" + hex16 let peerID = PeerID(str: str) #expect(peerID.id == str) #expect(peerID.bare == hex16) #expect(peerID.prefix == .geoDM) } @Test func geoDM_prefix_with64() { let str = "nostr_" + hex64 let peerID = PeerID(str: str) #expect(peerID.id == str) #expect(peerID.bare == hex64) #expect(peerID.prefix == .geoDM) } // MARK: - GeoChat prefix @Test func geoChat_prefix_with16() { let str = "nostr:" + hex16 let peerID = PeerID(str: str) #expect(peerID.id == str) #expect(peerID.bare == hex16) #expect(peerID.prefix == .geoChat) } @Test func geoChat_prefix_with64() { let str = "nostr:" + hex64 let peerID = PeerID(str: str) #expect(peerID.id == str) #expect(peerID.bare == hex64) #expect(peerID.prefix == .geoChat) } // MARK: - Edge cases @Test func with_unknown_prefix() { let str = "unknown:" + hex16 let peerID = PeerID(str: str) // Falls back to .empty #expect(peerID.id == str) #expect(peerID.bare == str) #expect(peerID.prefix == .empty) } @Test func with_only_prefix_no_bare() { let str = "mesh:" let peerID = PeerID(str: str) #expect(peerID.id == str) #expect(peerID.bare == "") #expect(peerID.prefix == .mesh) } // MARK: - init?(data:) @Test func data_valid_utf8() { let peerID = PeerID(data: Data(hex16.utf8)) #expect(peerID != nil) #expect(peerID?.bare == hex16) #expect(peerID?.prefix == .empty) } @Test func data_invalid_utf8() { // Random invalid UTF8 let bytes: [UInt8] = [0xFF, 0xFE, 0xFA] let peerID = PeerID(data: Data(bytes)) #expect(peerID == nil) } // MARK: - init(str: Substring) @Test func substring() { let substring = hex64.prefix(16) let peerID = PeerID(str: substring) #expect(peerID.id == String(substring)) #expect(peerID.bare == String(substring)) #expect(peerID.prefix == .empty) } // MARK: - init(nostr_ pubKey:) @Test func nostrUnderscore_pubKey() { let pubKey = hex64 let peerID = PeerID(nostr_: pubKey) #expect(peerID.id == "nostr_\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))") #expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))) #expect(peerID.prefix == .geoDM) } // MARK: - init(nostr pubKey:) @Test func nostr_pubKey() { let pubKey = hex64 let peerID = PeerID(nostr: pubKey) #expect(peerID.id == "nostr:\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))") #expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))) #expect(peerID.prefix == .geoChat) } // MARK: - init(publicKey:) @Test func publicKey_derivesFingerprint() { let publicKey = Data(hex64.utf8) let expected = publicKey.sha256Fingerprint().prefix(16) let peerID = PeerID(publicKey: publicKey) #expect(peerID.bare == String(expected)) #expect(peerID.prefix == .empty) } // MARK: - toShort() @Test func toShort_whenNoiseKeyExists() { let peerID = PeerID(str: hex64) let short = peerID.toShort() let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16) #expect(short.bare == String(expected)) #expect(short.prefix == .empty) } @Test func toShort_whenNoiseKeyExists_withNoisePrefix() { let peerID = PeerID(str: "noise:" + hex64) let short = peerID.toShort() let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16) #expect(short.bare == String(expected)) #expect(short.prefix == .empty) #expect(peerID.prefix == .noise) } @Test func toShort_whenNoNoiseKey() { let peerID = PeerID(str: "some_random_key") let short = peerID.toShort() #expect(short == peerID) } @Test func routingData_fromShortID() throws { let peerID = PeerID(str: hex16) let routing = try #require(peerID.routingData) #expect(routing.count == 8) #expect(routing == Data(hexString: hex16)) } @Test func routingData_fromNoiseKey() throws { let peerID = PeerID(str: hex64) let routing = try #require(peerID.routingData) let expectedShort = peerID.toShort() #expect(routing == Data(hexString: expectedShort.id)) } @Test func routingPeerRoundTrip() throws { let raw = try #require(Data(hexString: hex16)) let peerID = try #require(PeerID(routingData: raw)) #expect(peerID.routingData == raw) } // MARK: - Codable @Test func codable_emptyPrefix() throws { struct Dummy: Codable, Equatable { let name: String let peerID: PeerID } let str = "aabbccddeeff0011" let jsonString = "{\"name\":\"some name\",\"peerID\":\"\(str)\"}" let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8)) #expect(decoded.peerID == PeerID(str: str)) let encoded = try encoder.encode(decoded) #expect(String(data: encoded, encoding: .utf8) == jsonString) } @Test func codable_withPrefix() throws { struct Dummy: Codable, Equatable { let peerID: PeerID } let str = "nostr_\(hex16)" let jsonString = "{\"peerID\":\"\(str)\"}" let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8)) #expect(decoded.peerID == PeerID(str: str)) #expect(decoded.peerID.bare == hex16) #expect(decoded.peerID.prefix == .geoDM) let encoded = try encoder.encode(decoded) #expect(String(data: encoded, encoding: .utf8) == jsonString) } @Test func codable_multiplePrefixes() throws { // Loop across all Prefix cases (except .empty since already tested) for prefix in PeerID.Prefix.allCases where prefix != .empty { let bare = hex16 let str = prefix.rawValue + bare let decoded = try JSONDecoder().decode(PeerID.self, from: Data("\"\(str)\"".utf8)) #expect(decoded.prefix == prefix) #expect(decoded.bare == bare) let encoded = try encoder.encode(decoded) #expect(String(data: encoded, encoding: .utf8) == "\"\(str)\"") } } // MARK: - Comparable @Test func comparable_sorting_and_equality() { let p1 = PeerID(str: "aaa") let p2 = PeerID(str: "bbb") let p3 = PeerID(str: "BBB") #expect(p1 < p2) #expect(p2 >= p1) #expect(p2 == p3) let sorted = [p2, p1].sorted() #expect(sorted == [p1, p2]) } @Test func equality() { let peerID = PeerID(str: "aaa") // Regular PeerID <> PeerID #expect(peerID == PeerID(str: "AAA")) #expect(peerID == Optional(PeerID(str: "AAA"))) #expect(PeerID(str: "AAA") == peerID) #expect(Optional(PeerID(str: "AAA")) == Optional(peerID)) #expect(peerID != PeerID(str: "BBB")) #expect(peerID != Optional(PeerID(str: "BBB"))) #expect(PeerID(str: "BBB") != peerID) #expect(Optional(PeerID(str: "BBB")) != Optional(peerID)) } // MARK: - Computed properties @Test func isEmpty_true_and_false() { #expect(PeerID(str: "").isEmpty) #expect(!PeerID(str: "abc").isEmpty) } @Test func isGeoChat() { #expect(PeerID(str: "nostr:abcdef").isGeoChat) #expect(!PeerID(str: "nostr_abcdef").isGeoChat) } @Test func isGeoDM() { #expect(PeerID(str: "nostr_abcdef").isGeoDM) #expect(!PeerID(str: "nostr:abcdef").isGeoDM) } @Test func toPercentEncoded() { let peerID = PeerID(str: "name:some value/with spaces?") let encoded = peerID.toPercentEncoded() // spaces and ? should be percent-encoded in urlPathAllowed #expect(encoded == "name%3Asome%20value/with%20spaces%3F") } // MARK: - Validation @Test func accepts_short_hex_peer_id() { #expect(PeerID(str: "0011223344556677").isValid) #expect(PeerID(str: "aabbccddeeff0011").isValid) } @Test func accepts_full_noise_key_hex() { let hex64 = String(repeating: "ab", count: 32) // 64 hex chars #expect(PeerID(str: hex64).isValid) } @Test func accepts_internal_alnum_dash_underscore() { #expect(PeerID(str: "peer_123-ABC").isValid) #expect(PeerID(str: "nostr_user_01").isValid) } @Test func rejects_invalid_characters() { #expect(!PeerID(str: "peer!@#").isValid) #expect(!PeerID(str: "gggggggggggggggg").isValid) // not hex for short form } @Test func rejects_too_long() { let tooLong = String(repeating: "a", count: 65) #expect(!PeerID(str: tooLong).isValid) } @Test func isShort() { #expect(PeerID(str: hex16).isShort) #expect(!PeerID(str: "abcd").isShort) // wrong length } @Test func isNoiseKeyHex_and_noiseKey() { let hex64 = String(repeating: "ab", count: 32) // 64 chars valid hex let peerID = PeerID(str: hex64) #expect(peerID.isNoiseKeyHex) #expect(peerID.noiseKey != nil) let prefixedPeerID = PeerID(str: "noise:" + hex64) #expect(prefixedPeerID.isNoiseKeyHex) #expect(prefixedPeerID.noiseKey != nil) let bad = String(repeating: "z", count: 64) // invalid hex let badPeerID = PeerID(str: bad) #expect(!badPeerID.isNoiseKeyHex) #expect(badPeerID.noiseKey == nil) } @Test func prefixes() { let hex64 = String(repeating: "a", count: 64) #expect(PeerID(str: "noise:\(hex64)").isValid) #expect(PeerID(str: "nostr:\(hex64)").isValid) #expect(PeerID(str: "nostr_\(hex64)").isValid) let hex63 = String(repeating: "a", count: 63) #expect(PeerID(str: "noise:\(hex63)").isValid) #expect(PeerID(str: "nostr:\(hex63)").isValid) #expect(PeerID(str: "nostr_\(hex63)").isValid) let hex16 = String(repeating: "a", count: 16) #expect(PeerID(str: "noise:\(hex16)").isValid) #expect(PeerID(str: "nostr:\(hex16)").isValid) #expect(PeerID(str: "nostr_\(hex16)").isValid) let hex8 = String(repeating: "a", count: 8) #expect(PeerID(str: "noise:\(hex8)").isValid) #expect(PeerID(str: "nostr:\(hex8)").isValid) #expect(PeerID(str: "nostr_\(hex8)").isValid) let mesh = "mesh:abcdefg" #expect(PeerID(str: "name:\(mesh)").isValid) let name = "name:some_name" #expect(PeerID(str: "name:\(name)").isValid) let badName = "name:bad:name" #expect(!PeerID(str: "name:\(badName)").isValid) // Too long let hex65 = String(repeating: "a", count: 65) #expect(!PeerID(str: "noise:\(hex65)").isValid) #expect(!PeerID(str: "nostr:\(hex65)").isValid) #expect(!PeerID(str: "nostr_\(hex65)").isValid) } // MARK: - File Transfer PeerID Normalization // These tests verify the fix for asymmetric voice/media delivery (BCH-01-XXX) // The bug occurred when selectedPrivateChatPeer was migrated to 64-hex stable key // but the receiver expected SHA256-derived 16-hex format @Test func fileTransfer_toShortNormalizesNoiseKeyToFingerprint() { // Given: A 64-hex Noise public key (what selectedPrivateChatPeer becomes after session) let noiseKey = Data(repeating: 0xAB, count: 32) let stableKeyPeerID = PeerID(hexData: noiseKey) // 64-hex // When: Convert to short form (what sendFilePrivate should do) let shortID = stableKeyPeerID.toShort() // Then: Should be 16-hex SHA256 fingerprint (matching myPeerID format) let expected = noiseKey.sha256Fingerprint().prefix(16) #expect(shortID.id == String(expected)) #expect(shortID.id.count == 16) } @Test func fileTransfer_shortIDMatchesMyPeerIDFormat() { // Given: A receiver's myPeerID is SHA256-derived (from refreshPeerIdentity) let noiseKey = Data(repeating: 0xCD, count: 32) let myPeerID = PeerID(publicKey: noiseKey) // SHA256-derived 16-hex // When: Sender uses toShort() on 64-hex stable key let senderStableKey = PeerID(hexData: noiseKey) // 64-hex let recipientData = Data(hexString: senderStableKey.toShort().id)! let receivedRecipientID = PeerID(hexData: recipientData) // Then: Should match receiver's myPeerID (file transfer accepted) #expect(receivedRecipientID == myPeerID) } @Test func fileTransfer_truncatedRawKeyDoesNotMatchMyPeerID() { // This test demonstrates the bug we fixed // When 64-hex was truncated to first 8 bytes instead of using SHA256 fingerprint // Given: Receiver's myPeerID is SHA256-derived let noiseKey = Data(repeating: 0xEF, count: 32) let myPeerID = PeerID(publicKey: noiseKey) // SHA256-derived 16-hex // When: Truncate raw key (the OLD buggy behavior) let truncatedRaw = noiseKey.prefix(8) // First 8 bytes of raw key let wrongRecipientID = PeerID(hexData: truncatedRaw) // Then: Should NOT match (demonstrates why fix was needed) #expect(wrongRecipientID != myPeerID) } @Test func fileTransfer_shortIDProducesCorrect8ByteRoutingData() { // Verify the wire format is correct (8 bytes for BinaryProtocol) let noiseKey = Data(repeating: 0x12, count: 32) let stableKeyPeerID = PeerID(hexData: noiseKey) let shortID = stableKeyPeerID.toShort() // routingData should be 8 bytes (16 hex chars -> 8 bytes) let routingData = shortID.routingData #expect(routingData != nil) #expect(routingData?.count == 8) // And it should match SHA256 fingerprint first 8 bytes let expectedFingerprint = noiseKey.sha256Fingerprint() let expectedFirst8 = Data(hexString: String(expectedFingerprint.prefix(16))) #expect(routingData == expectedFirst8) } } ================================================ FILE: bitchatTests/ViewSmokeTests.swift ================================================ import Testing import Foundation import SwiftUI import CoreGraphics import AVFoundation #if os(iOS) import UIKit #else import AppKit #endif @testable import bitchat @MainActor private func makeSmokeViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identityManager: MockIdentityManager) { let keychain = MockKeychain() let keychainHelper = MockKeychainHelper() let idBridge = NostrIdentityBridge(keychain: keychainHelper) let identityManager = MockIdentityManager(keychain) let transport = MockTransport() let viewModel = ChatViewModel( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: transport ) return (viewModel, transport, identityManager) } @MainActor @discardableResult private func mount(_ view: V) -> AnyObject { #if os(iOS) let host = UIHostingController(rootView: view) _ = host.view host.view.setNeedsLayout() host.view.layoutIfNeeded() return host #else let host = NSHostingView(rootView: view) host.layoutSubtreeIfNeeded() _ = host.fittingSize return host #endif } private func makeSnapshot( peerID: PeerID, nickname: String, connected: Bool = true, noiseByte: UInt8 ) -> TransportPeerSnapshot { TransportPeerSnapshot( peerID: peerID, nickname: nickname, isConnected: connected, noisePublicKey: Data(repeating: noiseByte, count: 32), lastSeen: Date() ) } private func makeCGImage() throws -> CGImage { let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() let context = try #require( CGContext( data: nil, width: 8, height: 8, bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) ) context.setFillColor(CGColor(red: 0.1, green: 0.7, blue: 0.2, alpha: 1)) context.fill(CGRect(x: 0, y: 0, width: 8, height: 8)) return try #require(context.makeImage()) } private func makeTemporaryAudioURL() throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) .appendingPathExtension("caf") let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1)) let frameCount: AVAudioFrameCount = 1_600 let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount)) buffer.frameLength = frameCount let channel = try #require(buffer.floatChannelData?[0]) for index in 0.. URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) .appendingPathExtension("png") let image = try makeCGImage() #if os(iOS) let data = try #require(UIImage(cgImage: image).pngData()) #else let rep = NSBitmapImageRep(cgImage: image) let data = try #require(rep.representation(using: .png, properties: [:])) #endif try data.write(to: url) return url } @MainActor struct ViewSmokeTests { @Test func fingerprintView_renders_verifiedAndPendingStates() async { let (viewModel, transport, _) = makeSmokeViewModel() let verifiedPeer = PeerID(str: "0102030405060708") let pendingPeer = PeerID(str: "1112131415161718") let verifiedFingerprint = String(repeating: "ab", count: 32) transport.peerFingerprints[verifiedPeer] = verifiedFingerprint transport.peerFingerprints[pendingPeer] = nil transport.updatePeerSnapshots([ makeSnapshot(peerID: verifiedPeer, nickname: "Alice", noiseByte: 0x11), makeSnapshot(peerID: pendingPeer, nickname: "Bob", noiseByte: 0x22) ]) try? await Task.sleep(nanoseconds: 50_000_000) viewModel.verifiedFingerprints.insert(verifiedFingerprint) let verifiedView = FingerprintView(viewModel: viewModel, peerID: verifiedPeer) let pendingView = FingerprintView(viewModel: viewModel, peerID: pendingPeer) _ = verifiedView.body _ = pendingView.body _ = mount(verifiedView) _ = mount(pendingView) #expect(viewModel.verifiedFingerprints.contains(verifiedFingerprint)) } @Test func verificationViews_renderCoreBranches() throws { let (viewModel, transport, _) = makeSmokeViewModel() let peerID = PeerID(str: "2122232425262728") let fingerprint = String(repeating: "cd", count: 32) var isPresented = true transport.peerFingerprints[peerID] = fingerprint transport.updatePeerSnapshots([makeSnapshot(peerID: peerID, nickname: "Verifier", noiseByte: 0x33)]) viewModel.selectedPrivateChatPeer = peerID viewModel.verifiedFingerprints.insert(fingerprint) let image = try makeCGImage() let myQR = MyQRView(qrString: "bitchat://verify?name=alice&npub=npub1test") let qrCode = QRCodeImage(data: "bitchat://verify?hello=world", size: 96) let imageWrapper = ImageWrapper(image: image) _ = myQR.body _ = qrCode.body _ = imageWrapper.body _ = mount(myQR) _ = mount(qrCode) _ = mount(imageWrapper) _ = mount( VerificationSheetView( isPresented: Binding( get: { isPresented }, set: { isPresented = $0 } ) ) .environmentObject(viewModel) ) } @Test func meshPeerList_renders_emptyAndPopulatedStates() async { let (viewModel, transport, identityManager) = makeSmokeViewModel() let connectedPeer = PeerID(str: "3132333435363738") let blockedPeer = PeerID(str: "4142434445464748") let blockedFingerprint = String(repeating: "ef", count: 32) _ = mount( MeshPeerList( viewModel: viewModel, textColor: .green, secondaryTextColor: .gray, onTapPeer: { _ in }, onToggleFavorite: { _ in }, onShowFingerprint: { _ in } ) ) _ = MeshPeerList( viewModel: viewModel, textColor: .green, secondaryTextColor: .gray, onTapPeer: { _ in }, onToggleFavorite: { _ in }, onShowFingerprint: { _ in } ).body transport.peerFingerprints[blockedPeer] = blockedFingerprint identityManager.setBlocked(blockedFingerprint, isBlocked: true) transport.updatePeerSnapshots([ makeSnapshot(peerID: connectedPeer, nickname: "Alice", noiseByte: 0x44), makeSnapshot(peerID: blockedPeer, nickname: "Mallory", noiseByte: 0x55) ]) try? await Task.sleep(nanoseconds: 50_000_000) viewModel.unreadPrivateMessages.insert(blockedPeer) _ = mount( MeshPeerList( viewModel: viewModel, textColor: .green, secondaryTextColor: .gray, onTapPeer: { _ in }, onToggleFavorite: { _ in }, onShowFingerprint: { _ in } ) ) #expect(viewModel.hasUnreadMessages(for: blockedPeer)) } @Test func commandSuggestionsAndLocationViews_render() { let (viewModel, _, _) = makeSmokeViewModel() let channel = GeohashChannel(level: .city, geohash: "u4pruy") var messageText = "/f" LocationChannelManager.shared.select(.location(channel)) _ = mount( CommandSuggestionsView( messageText: Binding( get: { messageText }, set: { messageText = $0 } ), textColor: .green, backgroundColor: .black, secondaryTextColor: .gray ) .environmentObject(viewModel) ) _ = mount( LocationChannelsSheet(isPresented: .constant(true)) .environmentObject(viewModel) ) #expect(messageText == "/f") LocationChannelManager.shared.select(.mesh) LocationChannelManager.shared.endLiveRefresh() } @Test func locationNotesView_rendersNoRelayAndLoadedStates() throws { let (viewModel, _, _) = makeSmokeViewModel() let noRelayManager = LocationNotesManager( geohash: "u4pruydq", dependencies: LocationNotesDependencies( relayLookup: { _, _ in [] }, subscribe: { _, _, _, _, _ in }, unsubscribe: { _ in }, sendEvent: { _, _ in }, deriveIdentity: { _ in try NostrIdentity.generate() }, now: { Date() } ) ) var noteHandler: ((NostrEvent) -> Void)? var eose: (() -> Void)? let loadedManager = LocationNotesManager( geohash: "u4pruydq", dependencies: LocationNotesDependencies( relayLookup: { _, _ in ["wss://relay.one"] }, subscribe: { _, _, _, handler, onEOSE in noteHandler = handler eose = onEOSE }, unsubscribe: { _ in }, sendEvent: { _, _ in }, deriveIdentity: { _ in try NostrIdentity.generate() }, now: { Date() } ) ) let identity = try NostrIdentity.generate() let event = try NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .textNote, tags: [["g", "u4pruydq"], ["n", "Builder"]], content: "hello from a note" ).sign(with: identity.schnorrSigningKey()) noteHandler?(event) eose?() _ = mount( LocationNotesView(geohash: "u4pruydq", manager: noRelayManager) .environmentObject(viewModel) ) _ = mount( LocationNotesView(geohash: "u4pruydq", manager: loadedManager) .environmentObject(viewModel) ) #expect(loadedManager.notes.count == 1) #expect(noRelayManager.state == .noRelays) } @Test func appInfoAndComponentViews_render() { let feature = AppInfoFeatureInfo( icon: "lock.fill", title: "app_info.privacy.title", description: "app_info.features.encryption.description" ) let appInfo = AppInfoView() let header = SectionHeader("app_info.features.title") let featureRow = FeatureRow(info: feature) let paymentCashu = PaymentChipView(paymentType: .cashu("cashuA_test-token")) let paymentLightning = PaymentChipView(paymentType: .lightning("lightning:lnbc1test")) _ = appInfo.body _ = header.body _ = featureRow.body _ = paymentCashu.body _ = paymentLightning.body _ = DeliveryStatusView(status: .sending).body _ = DeliveryStatusView(status: .sent).body _ = DeliveryStatusView(status: .delivered(to: "Alice", at: Date())).body _ = DeliveryStatusView(status: .read(by: "Alice", at: Date())).body _ = DeliveryStatusView(status: .failed(reason: "offline")).body _ = DeliveryStatusView(status: .partiallyDelivered(reached: 2, total: 3)).body _ = mount(appInfo) _ = mount(header) _ = mount(featureRow) _ = mount(paymentCashu) _ = mount(paymentLightning) #expect(PaymentChipView.PaymentType.cashu("cashuA_test-token").url?.scheme == "cashu") #expect(PaymentChipView.PaymentType.cashu("https://example.com/cashu").url?.absoluteString == "https://example.com/cashu") #expect(PaymentChipView.PaymentType.lightning("lightning:lnbc1test").url?.scheme == "lightning") } @Test func geohashAndTextMessageViews_renderCoreBranches() { let (viewModel, _, _) = makeSmokeViewModel() let geohashPeopleList = GeohashPeopleList( viewModel: viewModel, textColor: .green, secondaryTextColor: .gray, onTapPerson: {} ) var expandedMessageIDs: Set = [] let longMessage = BitchatMessage( sender: viewModel.nickname, content: String(repeating: "verylongtoken", count: 12) + " lightning:lnbc1test cashuA_test-token", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "Bob", deliveryStatus: .partiallyDelivered(reached: 1, total: 2) ) _ = geohashPeopleList.body _ = mount(geohashPeopleList) _ = mount( TextMessageView( message: longMessage, expandedMessageIDs: Binding( get: { expandedMessageIDs }, set: { expandedMessageIDs = $0 } ) ) .environmentObject(viewModel) ) #expect(expandedMessageIDs.isEmpty) } @Test func voiceAndMediaViews_renderAndWarmCaches() async throws { let audioURL = try makeTemporaryAudioURL() let imageURL = try makeTemporaryImageURL() defer { try? FileManager.default.removeItem(at: audioURL) try? FileManager.default.removeItem(at: imageURL) WaveformCache.shared.purge(url: audioURL) } let waveformView = WaveformView( samples: [0.1, 0.6, 0.3, 0.8], playbackProgress: 0.25, sendProgress: 0.75, onSeek: nil, isInteractive: false ) let imageView = BlockRevealImageView( url: imageURL, revealProgress: 0.5, isSending: true, onCancel: {}, initiallyBlurred: true, onOpen: {}, onDelete: {} ) let voiceNoteView = VoiceNoteView( url: audioURL, isSending: true, sendProgress: 0.4, onCancel: {} ) let playback = VoiceNotePlaybackController(url: audioURL) _ = waveformView.body _ = imageView.body _ = mount(waveformView) _ = mount(imageView) _ = mount(voiceNoteView) let bins = await withCheckedContinuation { continuation in WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in continuation.resume(returning: values) } } playback.loadDuration() try? await Task.sleep(nanoseconds: 250_000_000) playback.seek(to: 1.25) playback.stop() VoiceNotePlaybackCoordinator.shared.activate(playback) VoiceNotePlaybackCoordinator.shared.deactivate(playback) VoiceRecorder.shared.cancelRecording() #expect(bins.count == 16) #expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16) #expect(playback.duration > 0) #expect(playback.progress == 0) #expect(VoiceRecorder.shared.currentAveragePower() <= 0) } #if os(iOS) @Test func cameraScannerView_previewAndCoordinatorSmoke() { let preview = CameraScannerView.PreviewView(frame: .zero) let coordinator = CameraScannerView.Coordinator() _ = CameraScannerView.PreviewView.layerClass _ = preview.videoPreviewLayer coordinator.setup(sessionOwner: preview) { _ in } coordinator.setActive(false) #expect(preview.videoPreviewLayer.videoGravity == .resizeAspectFill) } #endif } ================================================ FILE: bitchatTests/XChaCha20Poly1305CompatTests.swift ================================================ // // XChaCha20Poly1305CompatTests.swift // bitchatTests // // Tests for XChaCha20-Poly1305 encryption with proper error handling. // This is free and unencumbered software released into the public domain. // import Testing import struct Foundation.Data @testable import bitchat struct XChaCha20Poly1305CompatTests { @Test func sealAndOpenRoundtrip() throws { let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)! let key = Data(repeating: 0x42, count: 32) let nonce = Data(repeating: 0x24, count: 24) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce) let decrypted = try XChaCha20Poly1305Compat.open( ciphertext: sealed.ciphertext, tag: sealed.tag, key: key, nonce24: nonce ) #expect(decrypted == plaintext) } @Test func sealAndOpenWithAAD() throws { let plaintext = "Secret message".data(using: .utf8)! let key = Data(repeating: 0xAB, count: 32) let nonce = Data(repeating: 0xCD, count: 24) let aad = "additional authenticated data".data(using: .utf8)! let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad) let decrypted = try XChaCha20Poly1305Compat.open( ciphertext: sealed.ciphertext, tag: sealed.tag, key: key, nonce24: nonce, aad: aad ) #expect(decrypted == plaintext) } @Test func sealProducesDifferentCiphertextWithDifferentNonces() throws { let plaintext = "Same plaintext".data(using: .utf8)! let key = Data(repeating: 0x42, count: 32) let nonce1 = Data(repeating: 0x01, count: 24) let nonce2 = Data(repeating: 0x02, count: 24) let sealed1 = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce1) let sealed2 = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce2) #expect(sealed1.ciphertext != sealed2.ciphertext) } @Test func sealThrowsOnShortKey() { let plaintext = "Test".data(using: .utf8)! let shortKey = Data(repeating: 0x42, count: 16) let nonce = Data(repeating: 0x24, count: 24) var didThrow = false do { _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: shortKey, nonce24: nonce) } catch { didThrow = true } #expect(didThrow) } @Test func sealThrowsOnLongKey() { let plaintext = "Test".data(using: .utf8)! let longKey = Data(repeating: 0x42, count: 64) let nonce = Data(repeating: 0x24, count: 24) var didThrow = false do { _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: longKey, nonce24: nonce) } catch { didThrow = true } #expect(didThrow) } @Test func sealThrowsOnEmptyKey() { let plaintext = "Test".data(using: .utf8)! let emptyKey = Data() let nonce = Data(repeating: 0x24, count: 24) var didThrow = false do { _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: emptyKey, nonce24: nonce) } catch { didThrow = true } #expect(didThrow) } @Test func openThrowsOnInvalidKeyLength() { let ciphertext = Data(repeating: 0x00, count: 16) let tag = Data(repeating: 0x00, count: 16) let shortKey = Data(repeating: 0x42, count: 31) let nonce = Data(repeating: 0x24, count: 24) var didThrow = false do { _ = try XChaCha20Poly1305Compat.open(ciphertext: ciphertext, tag: tag, key: shortKey, nonce24: nonce) } catch { didThrow = true } #expect(didThrow) } @Test func sealThrowsOnShortNonce() { let plaintext = "Test".data(using: .utf8)! let key = Data(repeating: 0x42, count: 32) let shortNonce = Data(repeating: 0x24, count: 12) var didThrow = false do { _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: shortNonce) } catch { didThrow = true } #expect(didThrow) } @Test func sealThrowsOnLongNonce() { let plaintext = "Test".data(using: .utf8)! let key = Data(repeating: 0x42, count: 32) let longNonce = Data(repeating: 0x24, count: 32) var didThrow = false do { _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: longNonce) } catch { didThrow = true } #expect(didThrow) } @Test func sealThrowsOnEmptyNonce() { let plaintext = "Test".data(using: .utf8)! let key = Data(repeating: 0x42, count: 32) let emptyNonce = Data() var didThrow = false do { _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: emptyNonce) } catch { didThrow = true } #expect(didThrow) } @Test func openThrowsOnInvalidNonceLength() { let ciphertext = Data(repeating: 0x00, count: 16) let tag = Data(repeating: 0x00, count: 16) let key = Data(repeating: 0x42, count: 32) let shortNonce = Data(repeating: 0x24, count: 23) var didThrow = false do { _ = try XChaCha20Poly1305Compat.open(ciphertext: ciphertext, tag: tag, key: key, nonce24: shortNonce) } catch { didThrow = true } #expect(didThrow) } @Test func openFailsWithWrongKey() throws { let plaintext = "Secret".data(using: .utf8)! let correctKey = Data(repeating: 0x42, count: 32) let wrongKey = Data(repeating: 0x43, count: 32) let nonce = Data(repeating: 0x24, count: 24) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: correctKey, nonce24: nonce) var didThrow = false do { _ = try XChaCha20Poly1305Compat.open( ciphertext: sealed.ciphertext, tag: sealed.tag, key: wrongKey, nonce24: nonce ) } catch { didThrow = true } #expect(didThrow) } @Test func openFailsWithTamperedCiphertext() throws { let plaintext = "Secret".data(using: .utf8)! let key = Data(repeating: 0x42, count: 32) let nonce = Data(repeating: 0x24, count: 24) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce) // Create tampered ciphertext by changing first byte var tamperedBytes = [UInt8](sealed.ciphertext) tamperedBytes[0] = tamperedBytes[0] ^ 0xFF let tampered = Data(tamperedBytes) var didThrow = false do { _ = try XChaCha20Poly1305Compat.open( ciphertext: tampered, tag: sealed.tag, key: key, nonce24: nonce ) } catch { didThrow = true } #expect(didThrow) } } ================================================ FILE: docs/GeohashPresenceSpec.md ================================================ # Geohash Presence Specification ## Overview The Geohash Presence feature provides a mechanism to track online participants in geohash-based location channels. It uses a dedicated ephemeral Nostr event kind to broadcast "heartbeats," ensuring accurate and privacy-preserving online counts. ## Nostr Protocol ### Event Kind A new ephemeral event kind is defined for presence heartbeats: - **Kind:** `20001` (`GEOHASH_PRESENCE`) - **Type:** Ephemeral (not stored by relays long-term) ### Event Structure The presence event mimics the structure of a geohash chat message (Kind 20000) but without content or nickname metadata, to minimize overhead and focus purely on "liveness". ```json { "kind": 20001, "created_at": , "tags": [ ["g", ""] ], "content": "", "pubkey": "", "id": "", "sig": "" } ``` * **`content`**: Must be empty string. * **`tags`**: Must include `["g", ""]`. Should NOT include `["n", ""]`. * **`pubkey`**: The ephemeral identity derived specifically for this geohash (same as used for chat messages). ## Client Behavior ### 1. Broadcasting Presence Clients MUST broadcast a Kind 20001 presence event globally when the app is open, regardless of which screen the user is viewing. * **Global Heartbeat:** * **Trigger:** Application start / initialization, or whenever location (available geohashes) changes. * **Frequency:** Randomized loop interval between **40s and 80s** (average 60s). * **Scope:** Sent to *all* geohash channels corresponding to the device's *current physical location*. * **Privacy Restriction:** Presence MUST ONLY be broadcast to low-precision geohash levels to protect user privacy. Specifically: * **Allowed:** `REGION` (precision 2), `PROVINCE` (precision 4), `CITY` (precision 5). * **Denied:** `NEIGHBORHOOD` (precision 6), `BLOCK` (precision 7), `BUILDING` (precision 8+). * **Decorrelation:** Individual broadcasts within a heartbeat loop must be separated by random delays (e.g., 2-5 seconds) to prevent temporal correlation of public keys across different geohash levels. The main loop delay is adjusted to maintain the target average cadence. ### 2. Subscribing to Presence Clients must update their Nostr filters to listen for both chat and presence events on geohash channels. * **Filter:** * `kinds`: `[20000, 20001]` * `#g`: `[""]` ### 3. Participant Counting The "online participants" count shown in the UI aggregates unique public keys from both presence heartbeats and active chat messages. * **Logic:** * Maintain a map of `pubkey -> last_seen_timestamp` for each geohash. * Update `last_seen_timestamp` upon receiving a valid **Kind 20001 (Presence)** OR **Kind 20000 (Chat)** event. * A participant is considered "online" if their `last_seen_timestamp` is within the last **5 minutes**. ### 4. UI Presentation The presentation of the participant count depends on the geohash precision level and data availability. * **Standard Display:** For channels where presence is broadcast (Region, Province, City) OR any channel where at least one participant has been detected, show the exact count: `[N people]`. * **High-Precision Uncertainty:** For high-precision channels (Neighborhood, Block, Building) where: * Presence broadcasting is disabled (privacy restriction). * **AND** the detected participant count is `0`. * **Display:** `[? people]` * **Reasoning:** Since clients don't announce themselves in these channels, a count of "0" is misleading (people could be lurking). ### 5. Implementation Details (Android Reference) * **`NostrKind.GEOHASH_PRESENCE`**: Added constant `20001`. * **`NostrProtocol.createGeohashPresenceEvent`**: Helper to generate the event. * **`GeohashViewModel`**: * `startGlobalPresenceHeartbeat()`: Coroutine that `collectLatest` on `LocationChannelManager.availableChannels`. * Implements randomized loop logic (40-80s) and per-broadcast random delays (2-5s). * Filters channels by `precision <= 5` before broadcasting. * **`GeohashMessageHandler`**: * Refactored `onEvent` to update participant counts for both Kind 20000 and 20001. * **`LocationChannelsSheet`**: * Implements the `[? people]` display logic for high-precision, zero-count channels. ## Benefits * **Accuracy:** Counts reflect both active listeners (via heartbeats) and active speakers (via messages). * **Privacy:** High-precision location presence is NOT broadcast. Temporal correlation between different levels is obfuscated via random delays. * **Consistency:** "Online" status is maintained globally while the app is open. * **Transparency:** The UI correctly reflects uncertainty (`?`) when privacy rules prevent accurate passive counting. ================================================ FILE: docs/REQUEST_SYNC_MANAGER.md ================================================ # Request Sync Manager & V2 Packet Updates This document details the implementation of the Request Sync Manager and updates to the V2 packet structure to improve synchronization security and attribution on iOS, mirroring the Android implementation. ## Overview The goal of these changes is to make the request sync functionality "less blind". Previously, sync requests were broadcast, and responses were accepted without strict attribution or timestamp validation (to allow syncing old messages). This opened up potential spoofing vectors and prevented us from enforcing timestamp checks on normal traffic. The new implementation introduces a **RequestSyncManager** to track outgoing sync requests and attributes incoming responses (RSR - Request-Sync Response) to specific peers. This allows us to: 1. **Enforce Timestamp Validation**: Normal packets now require timestamps to be within 2 minutes of the local clock. 2. **Exempt Solicited Sync Responses**: Packets marked as RSR are exempt from timestamp validation *only if* they correspond to a valid, pending sync request sent to that specific peer. 3. **Prevent Unsolicited Sync Floods**: Unsolicited RSR packets are rejected. ## Protocol Changes ### Binary Protocol Updates * **New Flag**: `IS_RSR` (0x10) added to the packet header flags. * **BitchatPacket**: Updated to include `isRSR: Bool` field. * **Encoding/Decoding**: Updated `BinaryProtocol` to handle the new flag. ### Request Sync Payload The `REQUEST_SYNC` packet payload (TLV encoded) has been updated to include: * **Future Filters**: * `sinceTimestamp` (Type 0x05): To request packets since a certain time (UInt64 big-endian). * `fragmentIdFilter` (Type 0x06): To request specific fragments (UTF-8 string). ## Architecture ### RequestSyncManager A new component (`Sync/RequestSyncManager.swift`) responsible for: * **Tracking**: Stores `peerID -> timestamp` mappings for pending sync requests. * **Validation**: `isValidResponse(from: PeerID, isRSR: Bool)` checks if an incoming RSR packet matches a pending request within the 30-second window. * **Cleanup**: Periodically removes expired requests. ### GossipSyncManager Updates * **Unicast Sync**: Instead of blind broadcasting, the periodic sync task now iterates over connected peers and sends unicast `REQUEST_SYNC` packets. * **Registration**: Before sending, requests are registered with `RequestSyncManager`. * **Response Marking**: When responding to a `REQUEST_SYNC`, generated packets (Announce/Message) are explicitly marked with `isRSR = true` (and `ttl = 0`). ### BLEService (Security Manager) Updates * **Timestamp Enforcement**: Checks `abs(now - packetTimestamp) < 2 minutes` for standard packets. * **Conditional Exemption**: If `packet.isRSR` is true (or packet is a legacy TTL=0 response), it queries `RequestSyncManager`. * **Valid**: If solicited, timestamp check is skipped (allowing historical data sync). * **Invalid**: If unsolicited or timed out, the packet is rejected. ## Usage These changes are integrated into `BLEService` and `GossipSyncManager`. No external API changes are required for clients, but all peers must be updated to support the new `IS_RSR` flag and protocol logic to participate in the secure sync process. ================================================ FILE: docs/SOURCE_ROUTING.md ================================================ # Source-Based Routing for BitChat Packets (v2) This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays. **Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data). --- ## 1. Protocol Versioning & Layering To support source routing and larger payloads, the packet format has been upgraded to **Version 2**. * **Version 1 (Legacy):** 2-byte payload length limit. Ignores routing flags. * **Version 2 (Current):** 4-byte payload length limit. Supports Source Routing. **Key Rule:** The `HAS_ROUTE (0x08)` flag is **only valid** if the packet `version >= 2`. Relays receiving a v1 packet must ignore this flag even if set. --- ## 2. Packet Structure Comparison The following diagram illustrates the structural differences between a standard v1 packet and a source-routed v2 packet. ### V1 Packet (Legacy) ```text +-------------------+---------------------------------------------------------+ | Fixed Header (14) | Variable Sections | +-------------------+----------+-------------+------------------+-------------+ | Ver: 1 (1B) | SenderID | RecipientID | Payload | Signature | | Type, TTL, etc. | (8B) | (8B) | (Length in Head) | (64B) | | Len: 2 Bytes | | (Optional) | | (Optional) | +-------------------+----------+-------------+------------------+-------------+ ``` ### V2 Packet (Source Routed) ```text +-------------------+-----------------------------------------------------------------------------+ | Fixed Header (16) | Variable Sections | +-------------------+----------+-------------+-----------------------+------------------+-------------+ | Ver: 2 (1B) | SenderID | RecipientID | SOURCE ROUTE | Payload | Signature | | Type, TTL, etc. | (8B) | (8B) | (Variable) | (Length in Head) | (64B) | | Len: 4 Bytes | | (Required*) | Only if HAS_ROUTE=1 | | (Optional) | +-------------------+----------+-------------+-----------------------+------------------+-------------+ ``` **(*) Note:** A `Route` can be attached to **any** packet type that has a `RecipientID` (flag `HAS_RECIPIENT` set). ### Fixed Header Differences | Field | Size (v1) | Size (v2) | Description | |---|---|---|---| | **Version** | 1 byte | 1 byte | `0x01` vs `0x02` | | **Payload Length** | **2 bytes** | **4 bytes** | `UInt32` in v2 to support large files. **Excludes** route/IDs/sig. | | **Total Size** | **14 bytes** | **16 bytes** | V2 header is 2 bytes larger. | --- ## 3. Source Route Specification The `Source Route` field is a variable-length list of **intermediate hops** that the packet must traverse. * **Location:** Immediately follows `RecipientID`. * **Structure:** * `Count` (1 byte): Number of intermediate hops (`N`). * `Hops` (`N * 8` bytes): Sequence of Peer IDs. ### Intermediate Hops Only The route list MUST contain **only** the intermediate relays between the sender and the recipient. * **DO NOT** include the `SenderID` (it is already in the packet). * **DO NOT** include the `RecipientID` (it is already in the packet). **Example:** Topology: `Alice (Sender) -> Bob -> Charlie -> Dave (Recipient)` * Packet `SenderID`: Alice * Packet `RecipientID`: Dave * Packet `Route`: `[Bob, Charlie]` (Count = 2) --- ## 4. Topology Discovery (Gossip) To calculate routes, nodes need a view of the network topology. This is achieved via a **Neighbor List** extension to the `IdentityAnnouncement` packet. The `ANNOUNCE` packet payload now consists of a sequence of TLVs. The standard identity information is followed by an optional Gossip TLV. * **Mechanism:** Appended to the `IdentityAnnouncement` payload. * **New TLV Type:** `0x04` (Direct Neighbors). * **Content:** A list of Peer IDs that the announcing node is directly connected to. **TLV Structure (Type 0x04):** ```text [Type: 0x04] [Length: 1B] [NeighborID1 (8B)] [NeighborID2 (8B)] ... ``` The `Length` field indicates the total size of the neighbor IDs in bytes (N * 8). There is no explicit count field. Nodes receiving this TLV update their local mesh graph, linking the sender to the listed neighbors. ### Edge Verification (Two-Way Handshake) To prevent spoofing and routing through stale connections, the Mesh Graph service implements a strict two-way handshake verification: * **Unconfirmed Edge:** If Peer A announces Peer B, but Peer B does *not* announce Peer A, the connection is treated as **unconfirmed**. Unconfirmed edges are visualized as dotted lines in debug tools but are **excluded** from route calculations. * **Confirmed Edge:** An edge is only valid for routing when **both** peers explicitly announce each other in their neighbor lists. This ensures that the connection is bidirectional and currently active from both perspectives. --- ## 5. Fragmentation & Source Routing When a large source-routed packet (e.g., File Transfer) exceeds the MTU and requires fragmentation: 1. **Version Inheritance:** All fragments MUST be marked as **Version 2**. 2. **Route Inheritance:** All fragments MUST contain the **exact same Route field** as the parent packet. **Why?** If fragments were sent as v1 packets or without routes, they would fall back to flooding, negating the bandwidth benefits of source routing for large data transfers. --- ## 6. Security & Signing Source routing is fully secured by the existing Ed25519 signature scheme. * **Scope:** The signature covers the **entire packet structure** (Header + Sender + Recipient + Route + Payload). * **Verification:** The receiver verifies the signature against the `SenderID`'s public key. * **Integrity:** Any tampering with the route list by malicious relays will invalidate the signature, causing the packet to be dropped by the destination. **Signature Input Construction:** Serialize the packet exactly as transmitted, but temporarily set `TTL = 0` and remove the `Signature` bytes. --- ## 7. Relay Logic When a node receives a packet **not** addressed to itself: 1. **Check Route:** * Is `Version >= 2`? * Is `HAS_ROUTE` flag set? * Is the route list non-empty? 2. **If YES (Source Routed):** * Find local Peer ID in the route list at index `i`. * **Next Hop:** The peer at `i + 1`. * **Last Hop:** If `i` is the last index, the Next Hop is the `RecipientID`. * **Action:** Attempt to unicast (`sendToPeer`) to the Next Hop. * **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery. 3. **If NO (Standard):** * Flood the packet to all connected neighbors (subject to TTL and probability rules). ================================================ FILE: docs/TOR-INTEGRATION.md ================================================ Tor-by-default integration (scaffold) Overview - All network traffic is routed via a local Tor SOCKS5 proxy by default, with fail-closed behavior when Tor isn’t ready. There are no user-visible settings. - This repo now includes a minimal TorManager and TorURLSession to make dropping in an embedded Tor framework straightforward. Key pieces - TorManager - Boots Tor, manages a DataDirectory under Application Support, exposes SOCKS at 127.0.0.1:39050, and provides awaitReady(). - Fails closed by default until Tor is bootstrapped. For local development only, define BITCHAT_DEV_ALLOW_CLEARNET to bypass Tor. - TorURLSession - Provides a shared URLSession configured with a SOCKS5 proxy when Tor is enforced/ready. - NostrRelayManager and GeoRelayDirectory now use this session and await Tor readiness before starting network activity. Drop‑in steps 1) Build or obtain a small Tor framework - Recommended: Tor C (client-only) with static linking and dead-strip. - Configure Tor with a minimal feature set: ./configure \ --enable-static \ --disable-asciidoc --disable-unittests --disable-manpage \ --disable-zstd --disable-lzma --enable-zlib \ --disable-systemd --disable-ptrace --disable-seccomp CFLAGS="-Os -fdata-sections -ffunction-sections" \ LDFLAGS="-Wl,-dead_strip" - Build a tiny OpenSSL/LibreSSL (no engines, strip symbols) or reuse system crypto where permitted on macOS. 2) Add the framework to Xcode targets - Drop your xcframework into `Frameworks/`. The project is prewired in `project.yml` to link/embed `Frameworks/tor-nolzma.xcframework` (rename yours to match, or update the path). - Ensure the binary includes the slices you need (iOS device/simulator and/or macOS). If your xcframework lacks simulator slices, you can still build/run on device or macOS arm64; simulator will fail to link. - On iOS, it will be embedded and signed automatically. 3) Wire Tor bootstrap in TorManager.startTor() - Two paths are already implemented: - If a module named `Tor` is present (iCepa API), it starts `TORThread` directly. - Otherwise, it attempts a dynamic load (`dlopen`) of a bundled framework binary named `tor-nolzma.framework/tor-nolzma` (or `Tor.framework/Tor`), resolves `tor_run_main`, and launches Tor on a background thread. - `TorManager` writes a torrc and then probes `127.0.0.1:39050` until ready. 4) Verify networking - On app launch, TorManager.startIfNeeded() is called implicitly by awaitReady(). - NostrRelayManager.connect() awaits readiness, then creates WebSocket tasks via TorURLSession.shared. - GeoRelayDirectory.fetchRemote() awaits readiness, then fetches via TorURLSession.shared. 5) Optional macOS optimization - Detect a system Tor binary (e.g., /opt/homebrew/bin/tor) and run it as a subprocess to avoid bundling. Keep the embedded fallback for portability. torrc template The generated torrc (under Application Support/bitchat/tor/torrc) is: DataDirectory /bitchat/tor ClientOnly 1 SOCKSPort 127.0.0.1:39050 ControlPort 127.0.0.1:39051 CookieAuthentication 1 AvoidDiskWrites 1 MaxClientCircuitsPending 8 Dev bypass (local only) - To temporarily allow direct network without Tor for local development: - Add Swift compiler flag: BITCHAT_DEV_ALLOW_CLEARNET - This enables a clearnet session in TorURLSession when Tor isn’t present. - Never enable this in release builds. Notes - We intentionally do not change any app-level APIs: consumers simply use TorURLSession via existing code paths. - When Tor is missing in release builds, the app will not connect (fail-closed), logging a clear reason. ================================================ FILE: docs/privacy-assessment.md ================================================ BitChat Privacy Assessment ========================== Scope - Mesh transport (BLE) behavior and metadata minimization - Nostr-based private message fallback (gift-wrapped, end-to-end encrypted) - Read receipts and delivery acknowledgments - Logging/telemetry posture and controls Summary - No accounts, no servers for mesh; Nostr used only for mutual favorites, with end-to-end Noise encryption encapsulated in gift wraps. - BLE announces contain only nickname and Noise pubkey. No device name, no plaintext identity beyond what the user broadcasts. - Discovery and flooding incorporate jitter and TTL caps to reduce linkability and propagation radius of encrypted payloads. - UI and storage remain ephemeral; message content is not persisted to disk. Minimal state (e.g., read-receipt IDs) is stored for UX and is bounded/cleaned. - Logging defaults to conservative levels; debug verbosity is suppressed for release builds. A single env var can raise/lower threshold when needed. BLE Privacy Considerations - Announce content: Unchanged — nickname + Noise public key only. - Local Name: Not used (explicitly disabled). Avoids leaking device/OS identity. - Address: iOS uses BLE MAC randomization; BitChat does not attempt to set static addresses. - Announce jitter: Each announce is delayed by a small random jitter to avoid synchronization-based correlation. - Scanning: Foreground scanning uses “allow duplicates” briefly to improve discovery latency; background uses standard scanning parameters. - RSSI gating: The acceptance threshold adapts to nearby density (approx. -95 to -80 dBm) to reduce long-distance observations in dense areas and improve connectivity in sparse ones. - Fragmentation: Fragments use write-with-response for reliability (less re-broadcast churn = fewer repeated signals). - GATT permissions: Private characteristic disallows .read; we use notify/write/writeWithoutResponse to avoid exposing plaintext attributes over GATT. Mesh Routing and Multi-hop Limits - Encrypted relays permitted with random per-hop delay (small jitter) to smooth floods. - TTL cap: Encrypted payloads are capped at 2 hops, limiting metadata spread and path reconstruction risk while enabling close-range relays. Nostr Private Messaging Fallback - Usage criteria: Only attempted for mutual favorites or where a Nostr key has been exchanged (stored in favorites). - Payload confidentiality: Messages embed a BitChat Noise-encrypted packet inside a NIP-17 gift wrap; relays see only random-looking ciphertext. - Timestamp handling: Gift wraps add small randomized offsets to reduce exact timing correlation. - Read/delivery acks: Also encapsulated in gift wraps, preserving content secrecy and minimizing metadata. - Relay policy variance: Some relays apply “web-of-trust” policies and may reject events; BitChat tolerates partial delivery and still prefers mesh when available. Read Receipts and Delivery Acks - Routing policy: Prefer mesh if Noise session established; otherwise use Nostr when mapping exists. - Throttling: Nostr READ acks are queued and rate-limited (~3/s) to prevent relay rate limits during backlogs. - Coalescing (optional future): When entering a chat with many unread, only send READ for the latest message, marking older as read locally to reduce metadata. Data Retention and State - Messages: Ephemeral in-memory only; history is bounded per chat and trimmed. - Read-receipt IDs: Stored in `UserDefaults` for UX continuity; periodically pruned to IDs present in memory. - Favorites: Noise and optional Nostr keys with petnames; can be wiped via panic action. - Panic: Triple-tap clears keys, sessions, cached state, and disconnects transports. Logging and Telemetry - Centralized `SecureLogger` filters potential secrets and uses OSLog privacy markers. - Default level: `info`; release builds suppress debug. Developers can set `BITCHAT_LOG_LEVEL=debug|info|warning|error|fault`. - Transport routing, ACK sends, subscribe/connect noise were downgraded from info→debug. - OS/system errors (e.g., transient WebSocket disconnects) may still appear in system logs; BitChat avoids re-logging those unless actionable. Residual Risks and Mitigations - RF fingerprinting: BLE presence is observable at the RF layer; mitigated by minimal announce content and platform MAC randomization. - Timing correlation: Announce/relay jitter reduces but does not eliminate timing analysis. Avoids synchronized bursts. - Relay metadata: Nostr relays can see that an account posts gift wraps; content remains end-to-end encrypted. Favor mesh path when in range. Recommendations (Next) - Add optional coalesced READ behavior for large backlogs. - Expose a “low-visibility mode” to reduce scanning aggressiveness in sensitive contexts. - Allow user-configurable Nostr relay set with a “private relays only” toggle. ================================================ FILE: localPackages/Arti/.gitignore ================================================ /target/ /.build/ /.swiftpm/ ================================================ FILE: localPackages/Arti/Cargo.toml ================================================ [workspace] resolver = "2" members = ["arti-bitchat"] [profile.release] opt-level = "z" lto = "fat" codegen-units = 1 panic = "abort" strip = "symbols" ================================================ FILE: localPackages/Arti/Frameworks/arti.xcframework/Info.plist ================================================ AvailableLibraries BinaryPath libarti_bitchat.a HeadersPath Headers LibraryIdentifier macos-arm64 LibraryPath libarti_bitchat.a SupportedArchitectures arm64 SupportedPlatform macos BinaryPath libarti_bitchat.a HeadersPath Headers LibraryIdentifier ios-arm64 LibraryPath libarti_bitchat.a SupportedArchitectures arm64 SupportedPlatform ios BinaryPath libarti_bitchat.a HeadersPath Headers LibraryIdentifier ios-arm64-simulator LibraryPath libarti_bitchat.a SupportedArchitectures arm64 SupportedPlatform ios SupportedPlatformVariant simulator CFBundlePackageType XFWK XCFrameworkFormatVersion 1.0 ================================================ FILE: localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/Headers/arti.h ================================================ #ifndef ARTI_H #define ARTI_H #include #include /** * Start Arti with a SOCKS5 proxy. * * # Arguments * * `data_dir` - Path to data directory for Tor state (C string) * * `socks_port` - Port for SOCKS5 proxy (e.g., 39050) * * # Returns * * 0 on success * * -1 if already running * * -2 if data_dir is invalid * * -3 if runtime initialization failed * * -4 if bootstrap failed */ int arti_start(const char *data_dir, uint16_t socks_port); /** * Stop Arti gracefully. * * # Returns * * 0 on success * * -1 if not running */ int arti_stop(void); /** * Check if Arti is currently running. * * # Returns * * 1 if running * * 0 if not running */ int arti_is_running(void); /** * Get the current bootstrap progress (0-100). */ int arti_bootstrap_progress(void); /** * Get the current bootstrap summary string. * * # Arguments * * `buf` - Buffer to write the summary into * * `len` - Length of the buffer * * # Returns * * Number of bytes written (not including null terminator) * * -1 if buffer is null or too small */ int arti_bootstrap_summary(char *buf, int len); /** * Signal Arti to go dormant (reduce resource usage). * This is a hint; Arti may not fully support dormant mode yet. * * # Returns * * 0 on success * * -1 if not running */ int arti_go_dormant(void); /** * Signal Arti to wake from dormant mode. * * # Returns * * 0 on success * * -1 if not running */ int arti_wake(void); #endif /* ARTI_H */ ================================================ FILE: localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/Headers/arti.h ================================================ #ifndef ARTI_H #define ARTI_H #include #include /** * Start Arti with a SOCKS5 proxy. * * # Arguments * * `data_dir` - Path to data directory for Tor state (C string) * * `socks_port` - Port for SOCKS5 proxy (e.g., 39050) * * # Returns * * 0 on success * * -1 if already running * * -2 if data_dir is invalid * * -3 if runtime initialization failed * * -4 if bootstrap failed */ int arti_start(const char *data_dir, uint16_t socks_port); /** * Stop Arti gracefully. * * # Returns * * 0 on success * * -1 if not running */ int arti_stop(void); /** * Check if Arti is currently running. * * # Returns * * 1 if running * * 0 if not running */ int arti_is_running(void); /** * Get the current bootstrap progress (0-100). */ int arti_bootstrap_progress(void); /** * Get the current bootstrap summary string. * * # Arguments * * `buf` - Buffer to write the summary into * * `len` - Length of the buffer * * # Returns * * Number of bytes written (not including null terminator) * * -1 if buffer is null or too small */ int arti_bootstrap_summary(char *buf, int len); /** * Signal Arti to go dormant (reduce resource usage). * This is a hint; Arti may not fully support dormant mode yet. * * # Returns * * 0 on success * * -1 if not running */ int arti_go_dormant(void); /** * Signal Arti to wake from dormant mode. * * # Returns * * 0 on success * * -1 if not running */ int arti_wake(void); #endif /* ARTI_H */ ================================================ FILE: localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/Headers/arti.h ================================================ #ifndef ARTI_H #define ARTI_H #include #include /** * Start Arti with a SOCKS5 proxy. * * # Arguments * * `data_dir` - Path to data directory for Tor state (C string) * * `socks_port` - Port for SOCKS5 proxy (e.g., 39050) * * # Returns * * 0 on success * * -1 if already running * * -2 if data_dir is invalid * * -3 if runtime initialization failed * * -4 if bootstrap failed */ int arti_start(const char *data_dir, uint16_t socks_port); /** * Stop Arti gracefully. * * # Returns * * 0 on success * * -1 if not running */ int arti_stop(void); /** * Check if Arti is currently running. * * # Returns * * 1 if running * * 0 if not running */ int arti_is_running(void); /** * Get the current bootstrap progress (0-100). */ int arti_bootstrap_progress(void); /** * Get the current bootstrap summary string. * * # Arguments * * `buf` - Buffer to write the summary into * * `len` - Length of the buffer * * # Returns * * Number of bytes written (not including null terminator) * * -1 if buffer is null or too small */ int arti_bootstrap_summary(char *buf, int len); /** * Signal Arti to go dormant (reduce resource usage). * This is a hint; Arti may not fully support dormant mode yet. * * # Returns * * 0 on success * * -1 if not running */ int arti_go_dormant(void); /** * Signal Arti to wake from dormant mode. * * # Returns * * 0 on success * * -1 if not running */ int arti_wake(void); #endif /* ARTI_H */ ================================================ FILE: localPackages/Arti/Frameworks/include/arti.h ================================================ #ifndef ARTI_H #define ARTI_H #include #include /** * Start Arti with a SOCKS5 proxy. * * # Arguments * * `data_dir` - Path to data directory for Tor state (C string) * * `socks_port` - Port for SOCKS5 proxy (e.g., 39050) * * # Returns * * 0 on success * * -1 if already running * * -2 if data_dir is invalid * * -3 if runtime initialization failed * * -4 if bootstrap failed */ int arti_start(const char *data_dir, uint16_t socks_port); /** * Stop Arti gracefully. * * # Returns * * 0 on success * * -1 if not running */ int arti_stop(void); /** * Check if Arti is currently running. * * # Returns * * 1 if running * * 0 if not running */ int arti_is_running(void); /** * Get the current bootstrap progress (0-100). */ int arti_bootstrap_progress(void); /** * Get the current bootstrap summary string. * * # Arguments * * `buf` - Buffer to write the summary into * * `len` - Length of the buffer * * # Returns * * Number of bytes written (not including null terminator) * * -1 if buffer is null or too small */ int arti_bootstrap_summary(char *buf, int len); /** * Signal Arti to go dormant (reduce resource usage). * This is a hint; Arti may not fully support dormant mode yet. * * # Returns * * 0 on success * * -1 if not running */ int arti_go_dormant(void); /** * Signal Arti to wake from dormant mode. * * # Returns * * 0 on success * * -1 if not running */ int arti_wake(void); #endif /* ARTI_H */ ================================================ FILE: localPackages/Arti/Package.swift ================================================ // swift-tools-version:5.9 import PackageDescription let package = Package( name: "Tor", // Keep name "Tor" for drop-in compatibility platforms: [ .iOS(.v16), .macOS(.v13), ], products: [ .library( name: "Tor", targets: ["Tor"] ), ], dependencies: [ .package(path: "../BitLogger"), ], targets: [ // Main Swift target .target( name: "Tor", dependencies: [ "arti", .product(name: "BitLogger", package: "BitLogger"), ], path: "Sources", exclude: ["C"], sources: [ "TorManager.swift", "TorURLSession.swift", "TorNotifications.swift", ], linkerSettings: [ .linkedLibrary("resolv"), .linkedLibrary("z"), .linkedLibrary("sqlite3"), ] ), // Binary framework containing the Rust static library .binaryTarget( name: "arti", path: "Frameworks/arti.xcframework" ), ] ) ================================================ FILE: localPackages/Arti/Sources/C/arti_shim.c ================================================ // Empty shim file to satisfy SPM target requirements. // The actual implementation is in the Rust static library (arti.xcframework). // This file exists only to make SPM happy with a C target. ================================================ FILE: localPackages/Arti/Sources/C/include/arti.h ================================================ #ifndef ARTI_H #define ARTI_H #include #ifdef __cplusplus extern "C" { #endif /** * Start Arti with a SOCKS5 proxy. * * @param data_dir Path to data directory for Tor state (C string) * @param socks_port Port for SOCKS5 proxy (e.g., 39050) * @return 0 on success, negative on error: * -1: already running * -2: invalid data_dir * -3: runtime initialization failed * -4: bootstrap failed */ int32_t arti_start(const char *data_dir, uint16_t socks_port); /** * Stop Arti gracefully. * * @return 0 on success, -1 if not running */ int32_t arti_stop(void); /** * Check if Arti is currently running. * * @return 1 if running, 0 if not running */ int32_t arti_is_running(void); /** * Get the current bootstrap progress (0-100). * * @return Progress percentage */ int32_t arti_bootstrap_progress(void); /** * Get the current bootstrap summary string. * * @param buf Buffer to write the summary into * @param len Length of the buffer * @return Number of bytes written, -1 on error */ int32_t arti_bootstrap_summary(char *buf, int32_t len); /** * Signal Arti to go dormant (reduce resource usage). * * @return 0 on success, -1 if not running */ int32_t arti_go_dormant(void); /** * Signal Arti to wake from dormant mode. * * @return 0 on success, -1 if not running */ int32_t arti_wake(void); #ifdef __cplusplus } #endif #endif /* ARTI_H */ ================================================ FILE: localPackages/Arti/Sources/C/include/module.modulemap ================================================ module ArtiC { header "arti.h" export * } ================================================ FILE: localPackages/Arti/Sources/TorManager.swift ================================================ import BitLogger import Foundation #if canImport(Network) import Network #endif #if !canImport(Network) private final class NWPathMonitor { var pathUpdateHandler: ((Any) -> Void)? func start(queue: DispatchQueue) { // Path monitoring is unavailable on this platform; nothing to do. } } #endif // FFI declarations for Arti (Rust) @_silgen_name("arti_start") private func arti_start(_ dataDir: UnsafePointer, _ socksPort: UInt16) -> Int32 @_silgen_name("arti_stop") private func arti_stop() -> Int32 @_silgen_name("arti_is_running") private func arti_is_running() -> Int32 @_silgen_name("arti_bootstrap_progress") private func arti_bootstrap_progress() -> Int32 @_silgen_name("arti_bootstrap_summary") private func arti_bootstrap_summary(_ buf: UnsafeMutablePointer, _ len: Int32) -> Int32 @_silgen_name("arti_go_dormant") private func arti_go_dormant() -> Int32 @_silgen_name("arti_wake") private func arti_wake() -> Int32 /// Arti-based Tor integration for BitChat. /// - Boots a local Arti client and exposes a SOCKS5 proxy /// on 127.0.0.1:socksPort. All app networking should await readiness and /// route via this proxy. Fails closed by default when Tor is unavailable. @MainActor public final class TorManager: ObservableObject { public static let shared = TorManager() // SOCKS endpoint where Arti listens let socksHost: String = "127.0.0.1" let socksPort: Int = 39050 // State @Published private(set) public var isReady: Bool = false @Published private(set) var isStarting: Bool = false @Published private(set) var lastError: Error? @Published private(set) var bootstrapProgress: Int = 0 @Published private(set) var bootstrapSummary: String = "" // Internal readiness trackers private var socksReady: Bool = false { didSet { recomputeReady() } } private var restarting: Bool = false // Whether the app must enforce Tor for all connections (fail-closed). public var torEnforced: Bool { #if BITCHAT_DEV_ALLOW_CLEARNET return false #else return true #endif } // Returns true only when Tor is actually up (or dev fallback is compiled). var networkPermitted: Bool { if torEnforced { return isReady } return true } private var didStart = false private var bootstrapMonitorStarted = false private var pathMonitor: NWPathMonitor? private var isAppForeground: Bool = true private var isDormant: Bool = false private var lastRestartAt: Date? = nil private var startedAt: Date? = nil // Tracks initial startup time for grace period private(set) var allowAutoStart: Bool = false private init() {} // MARK: - Public API public func startIfNeeded() { guard allowAutoStart else { return } guard isAppForeground else { return } guard !didStart else { return } didStart = true isDormant = false isStarting = true startedAt = Date() // Track startup time for grace period SecureLogger.debug("TorManager: startIfNeeded() - startedAt set", category: .session) lastError = nil NotificationCenter.default.post(name: .TorWillStart, object: nil) ensureFilesystemLayout() startArti() startPathMonitorIfNeeded() } public func setAppForeground(_ foreground: Bool) { isAppForeground = foreground } public func isForeground() -> Bool { isAppForeground } nonisolated public func awaitReady(timeout: TimeInterval = 25.0) async -> Bool { await MainActor.run { if self.isAppForeground { self.startIfNeeded() } } let deadline = Date().addingTimeInterval(timeout) if await MainActor.run(body: { self.networkPermitted }) { return true } while Date() < deadline { try? await Task.sleep(nanoseconds: 200_000_000) if await MainActor.run(body: { self.networkPermitted }) { return true } } return await MainActor.run(body: { self.networkPermitted }) } // MARK: - Filesystem func dataDirectoryURL() -> URL? { do { let base = try FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true ) let dir = base.appendingPathComponent("bitchat/arti", isDirectory: true) return dir } catch { return nil } } private func ensureFilesystemLayout() { guard let dir = dataDirectoryURL() else { return } do { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } catch { // Non-fatal; Arti will surface errors during start if paths are missing } } // MARK: - Arti Integration private func startArti() { guard let dir = dataDirectoryURL()?.path else { isStarting = false lastError = NSError(domain: "TorManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data directory"]) return } // Check if already running if arti_is_running() != 0 { SecureLogger.info("TorManager: Arti already running", category: .session) startBootstrapMonitor() return } let result = dir.withCString { dptr in arti_start(dptr, UInt16(socksPort)) } if result != 0 { SecureLogger.error("TorManager: arti_start failed rc=\(result)", category: .session) isStarting = false lastError = NSError(domain: "TorManager", code: Int(result), userInfo: [NSLocalizedDescriptionKey: "Arti start failed"]) return } SecureLogger.info("TorManager: arti_start OK (SOCKS \(socksHost):\(socksPort))", category: .session) startBootstrapMonitor() // Start SOCKS readiness probe Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } let ready = await self.waitForSocksReady(timeout: 60.0) await MainActor.run { self.socksReady = ready if ready { SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session) } else { self.lastError = NSError(domain: "TorManager", code: -14, userInfo: [NSLocalizedDescriptionKey: "SOCKS not reachable"]) SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session) } } } } private func waitForSocksReady(timeout: TimeInterval) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if await probeSocksOnce() { return true } try? await Task.sleep(nanoseconds: 250_000_000) } return false } private func probeSocksOnce() async -> Bool { #if canImport(Network) await withCheckedContinuation { cont in let params = NWParameters.tcp let host = NWEndpoint.Host.ipv4(.loopback) guard let port = NWEndpoint.Port(rawValue: UInt16(socksPort)) else { cont.resume(returning: false) return } let endpoint = NWEndpoint.hostPort(host: host, port: port) let conn = NWConnection(to: endpoint, using: params) var resumed = false let resumeOnce: (Bool) -> Void = { value in if !resumed { resumed = true cont.resume(returning: value) } } conn.stateUpdateHandler = { state in switch state { case .ready: resumeOnce(true) conn.cancel() case .failed, .cancelled: resumeOnce(false) conn.cancel() default: break } } DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 1.0) { resumeOnce(false) conn.cancel() } conn.start(queue: DispatchQueue.global(qos: .utility)) } #else return false #endif } // MARK: - Bootstrap Monitoring private func startBootstrapMonitor() { guard !bootstrapMonitorStarted else { return } bootstrapMonitorStarted = true Task.detached(priority: .utility) { [weak self] in await self?.bootstrapPollLoop() } } private func bootstrapPollLoop() async { let deadline = Date().addingTimeInterval(75) while Date() < deadline { let progress = Int(arti_bootstrap_progress()) let summary = getBootstrapSummary() await MainActor.run { self.bootstrapProgress = progress self.bootstrapSummary = summary if progress >= 100 { self.isStarting = false } self.recomputeReady() } if progress >= 100 { break } try? await Task.sleep(nanoseconds: 1_000_000_000) } } private func getBootstrapSummary() -> String { var buf = [CChar](repeating: 0, count: 256) let len = arti_bootstrap_summary(&buf, Int32(buf.count)) if len > 0 { return String(cString: buf) } return "" } // MARK: - Foreground/Background public func ensureRunningOnForeground() { if !allowAutoStart { return } SecureLogger.debug("TorManager: ensureRunningOnForeground() started", category: .session) Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } let claimed: Bool = await MainActor.run { if self.isStarting || self.restarting { return false } self.restarting = true return true } if !claimed { return } // Check if already ready let alreadyReady = await MainActor.run { self.isReady } if alreadyReady { await MainActor.run { self.restarting = false } return } // Arti doesn't support dormant/wake (it's a no-op stub), so always do full restart await self.restartArti() await MainActor.run { self.restarting = false } } } public func goDormantOnBackground() { // Arti doesn't support real dormant mode, so just mark as not ready. // iOS will suspend the runtime anyway. On foreground we do a full restart. // Clear isStarting so foreground recovery can proceed if bootstrap was interrupted. SecureLogger.debug("TorManager: goDormantOnBackground() called", category: .session) Task { @MainActor in self.isReady = false self.socksReady = false self.isStarting = false } } public func shutdownCompletely() { SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session) Task.detached { [weak self] in guard let self = self else { return } _ = arti_stop() // Wait for shutdown var waited = 0 while arti_is_running() != 0 && waited < 50 { try? await Task.sleep(nanoseconds: 100_000_000) waited += 1 } await MainActor.run { self.isDormant = false self.isReady = false self.socksReady = false self.bootstrapProgress = 0 self.bootstrapSummary = "" self.isStarting = false self.didStart = false self.restarting = false self.bootstrapMonitorStarted = false // Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded() // Clearing it here races with startup and defeats the grace period } } } private func restartArti() async { SecureLogger.debug("TorManager: restartArti() starting", category: .session) await MainActor.run { NotificationCenter.default.post(name: .TorWillRestart, object: nil) self.isReady = false self.socksReady = false self.bootstrapProgress = 0 self.bootstrapSummary = "" self.isStarting = true self.isDormant = false self.lastRestartAt = Date() } _ = arti_stop() // Wait for stop var waited = 0 while arti_is_running() != 0 && waited < 40 { try? await Task.sleep(nanoseconds: 100_000_000) waited += 1 } await MainActor.run { self.bootstrapMonitorStarted = false self.didStart = false } await MainActor.run { self.startIfNeeded() } } private func recomputeReady() { let ready = socksReady && bootstrapProgress >= 100 if ready != isReady { if !ready { SecureLogger.debug("TorManager: isReady -> false (socksReady=\(socksReady), bootstrap=\(bootstrapProgress))", category: .session) } isReady = ready if ready { NotificationCenter.default.post(name: .TorDidBecomeReady, object: nil) } } } private func startPathMonitorIfNeeded() { #if canImport(Network) guard pathMonitor == nil else { return } let monitor = NWPathMonitor() pathMonitor = monitor let queue = DispatchQueue(label: "TorPathMonitor") monitor.pathUpdateHandler = { [weak self] _ in Task { @MainActor in guard let self = self else { return } if self.isAppForeground { self.pokeTorOnPathChange() } } } monitor.start(queue: queue) #endif } private func pokeTorOnPathChange() { // Skip if we recently restarted if let last = lastRestartAt, Date().timeIntervalSince(last) < 3.0 { SecureLogger.debug("TorManager: pokeTorOnPathChange() skipped - recent restart", category: .session) return } // Skip during initial startup grace period (15s) to avoid race conditions if let started = startedAt, Date().timeIntervalSince(started) < 15.0 { SecureLogger.debug("TorManager: pokeTorOnPathChange() skipped - startup grace period (\(Int(Date().timeIntervalSince(started)))s)", category: .session) return } if isStarting || restarting { SecureLogger.debug("TorManager: pokeTorOnPathChange() skipped - isStarting=\(isStarting) restarting=\(restarting)", category: .session) return } if isReady { return } SecureLogger.debug("TorManager: pokeTorOnPathChange() - Arti not ready, initiating recovery", category: .session) ensureRunningOnForeground() } } // MARK: - Start policy configuration extension TorManager { @MainActor public func setAutoStartAllowed(_ allow: Bool) { allowAutoStart = allow } @MainActor public func isAutoStartAllowed() -> Bool { allowAutoStart } } ================================================ FILE: localPackages/Arti/Sources/TorNotifications.swift ================================================ import Foundation public extension Notification.Name { static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady") static let TorWillRestart = Notification.Name("TorWillRestart") static let TorWillStart = Notification.Name("TorWillStart") static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged") } ================================================ FILE: localPackages/Arti/Sources/TorURLSession.swift ================================================ import Foundation #if os(macOS) import CFNetwork #endif /// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy /// when Tor is enforced/ready. Allows swapping between proxied and direct /// sessions so UI can toggle Tor usage at runtime. public final class TorURLSession { public static let shared = TorURLSession() // Default (no proxy) session for direct Nostr access when Tor is disabled. private var defaultSession: URLSession = TorURLSession.makeDefaultSession() // Proxied (SOCKS5) session that routes through Tor. private var torSession: URLSession = TorURLSession.makeTorSession() private var useTorProxy: Bool = true public var session: URLSession { useTorProxy ? torSession : defaultSession } // Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart. public func rebuild() { defaultSession = TorURLSession.makeDefaultSession() torSession = TorURLSession.makeTorSession() } public func setProxyMode(useTor: Bool) { guard useTorProxy != useTor else { return } useTorProxy = useTor rebuild() } private static func makeTorSession() -> URLSession { let cfg = URLSessionConfiguration.ephemeral cfg.waitsForConnectivity = true // Keep in sync with TorManager defaults let host = "127.0.0.1" let port = 39050 #if os(macOS) cfg.connectionProxyDictionary = [ kCFNetworkProxiesSOCKSEnable as String: 1, kCFNetworkProxiesSOCKSProxy as String: host, kCFNetworkProxiesSOCKSPort as String: port ] #else // iOS: CFNetwork SOCKS proxy keys are unavailable at compile time. cfg.connectionProxyDictionary = [ "SOCKSEnable": 1, "SOCKSProxy": host, "SOCKSPort": port ] #endif return URLSession(configuration: cfg) } private static func makeDefaultSession() -> URLSession { let cfg = URLSessionConfiguration.default cfg.waitsForConnectivity = true return URLSession(configuration: cfg) } } ================================================ FILE: localPackages/Arti/arti-bitchat/Cargo.toml ================================================ [package] name = "arti-bitchat" version = "0.1.0" edition = "2021" rust-version = "1.86" [lib] crate-type = ["staticlib"] [dependencies] # Arti core - minimal features for client-only SOCKS proxy arti-client = { version = "0.38", default-features = false, features = [ "tokio", "rustls", ] } # Async runtime tokio = { version = "1", default-features = false, features = [ "rt-multi-thread", "net", "sync", "time", "macros", ] } # Tor runtime compatibility tor-rtcompat = { version = "0.38", default-features = false, features = ["tokio"] } # FFI utilities libc = "0.2" once_cell = "1" # Logging (minimal) tracing = "0.1" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] } [features] default = [] ================================================ FILE: localPackages/Arti/arti-bitchat/cbindgen.toml ================================================ language = "C" include_guard = "ARTI_H" no_includes = true sys_includes = ["stdint.h", "stdbool.h"] [export] include = ["arti_start", "arti_stop", "arti_is_running", "arti_bootstrap_progress", "arti_bootstrap_summary", "arti_go_dormant", "arti_wake"] [fn] args = "Auto" [parse] parse_deps = false ================================================ FILE: localPackages/Arti/arti-bitchat/src/lib.rs ================================================ //! arti-bitchat: Minimal FFI wrapper around arti-client for BitChat //! //! Provides a C-compatible interface for embedding Arti (Rust Tor) in iOS/macOS apps. //! Exposes a SOCKS5 proxy on localhost that Swift code can route traffic through. use std::ffi::{c_char, c_int, CStr}; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::sync::{Arc, Mutex}; use arti_client::TorClient; use once_cell::sync::OnceCell; use tokio::net::TcpListener; use tokio::runtime::Runtime; use tokio::sync::oneshot; use tor_rtcompat::PreferredRuntime; mod socks; /// Global state for the Arti instance struct ArtiState { /// Tokio runtime (owned, single instance) runtime: Runtime, /// Shutdown signal sender shutdown_tx: Option>, /// TorClient handle for status queries client: Option>>, } static ARTI_STATE: OnceCell> = OnceCell::new(); static BOOTSTRAP_PROGRESS: AtomicI32 = AtomicI32::new(0); static IS_RUNNING: AtomicBool = AtomicBool::new(false); static BOOTSTRAP_SUMMARY: Mutex = Mutex::new(String::new()); /// Initialize the global state with a new runtime fn init_state() -> Result<(), &'static str> { ARTI_STATE.get_or_try_init(|| -> Result, &'static str> { let runtime = Runtime::new().map_err(|_| "Failed to create tokio runtime")?; Ok(Mutex::new(ArtiState { runtime, shutdown_tx: None, client: None, })) })?; Ok(()) } /// Start Arti with a SOCKS5 proxy. /// /// # Arguments /// * `data_dir` - Path to data directory for Tor state (C string) /// * `socks_port` - Port for SOCKS5 proxy (e.g., 39050) /// /// # Returns /// * 0 on success /// * -1 if already running /// * -2 if data_dir is invalid /// * -3 if runtime initialization failed /// * -4 if bootstrap failed #[no_mangle] pub extern "C" fn arti_start(data_dir: *const c_char, socks_port: u16) -> c_int { // Check if already running if IS_RUNNING.load(Ordering::SeqCst) { return -1; } // Parse data directory let data_path = match unsafe { CStr::from_ptr(data_dir) }.to_str() { Ok(s) => PathBuf::from(s), Err(_) => return -2, }; // Initialize runtime if needed if let Err(_) = init_state() { return -3; } let state = match ARTI_STATE.get() { Some(s) => s, None => return -3, }; let mut guard = match state.lock() { Ok(g) => g, Err(_) => return -3, }; // Create shutdown channel let (shutdown_tx, shutdown_rx) = oneshot::channel(); guard.shutdown_tx = Some(shutdown_tx); let socks_addr: SocketAddr = format!("127.0.0.1:{}", socks_port) .parse() .expect("valid addr"); // Spawn the main Arti task let data_path_clone = data_path.clone(); guard.runtime.spawn(async move { match run_arti(data_path_clone, socks_addr, shutdown_rx).await { Ok(_) => { tracing::info!("Arti shutdown cleanly"); } Err(e) => { tracing::error!("Arti error: {}", e); update_summary(&format!("Error: {}", e)); } } IS_RUNNING.store(false, Ordering::SeqCst); BOOTSTRAP_PROGRESS.store(0, Ordering::SeqCst); }); IS_RUNNING.store(true, Ordering::SeqCst); BOOTSTRAP_PROGRESS.store(0, Ordering::SeqCst); update_summary("Starting..."); 0 } /// Stop Arti gracefully. /// /// # Returns /// * 0 on success /// * -1 if not running #[no_mangle] pub extern "C" fn arti_stop() -> c_int { if !IS_RUNNING.load(Ordering::SeqCst) { return -1; } let state = match ARTI_STATE.get() { Some(s) => s, None => return -1, }; let mut guard = match state.lock() { Ok(g) => g, Err(_) => return -1, }; // Send shutdown signal if let Some(tx) = guard.shutdown_tx.take() { let _ = tx.send(()); } // Clear client reference guard.client = None; // Give async tasks time to complete std::thread::sleep(std::time::Duration::from_millis(200)); IS_RUNNING.store(false, Ordering::SeqCst); BOOTSTRAP_PROGRESS.store(0, Ordering::SeqCst); update_summary(""); 0 } /// Check if Arti is currently running. /// /// # Returns /// * 1 if running /// * 0 if not running #[no_mangle] pub extern "C" fn arti_is_running() -> c_int { if IS_RUNNING.load(Ordering::SeqCst) { 1 } else { 0 } } /// Get the current bootstrap progress (0-100). #[no_mangle] pub extern "C" fn arti_bootstrap_progress() -> c_int { BOOTSTRAP_PROGRESS.load(Ordering::SeqCst) } /// Get the current bootstrap summary string. /// /// # Arguments /// * `buf` - Buffer to write the summary into /// * `len` - Length of the buffer /// /// # Returns /// * Number of bytes written (not including null terminator) /// * -1 if buffer is null or too small #[no_mangle] pub extern "C" fn arti_bootstrap_summary(buf: *mut c_char, len: c_int) -> c_int { if buf.is_null() || len <= 0 { return -1; } let summary = match BOOTSTRAP_SUMMARY.lock() { Ok(s) => s.clone(), Err(_) => return -1, }; let bytes = summary.as_bytes(); let copy_len = std::cmp::min(bytes.len(), (len - 1) as usize); unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, copy_len); *buf.add(copy_len) = 0; // null terminator } copy_len as c_int } /// Signal Arti to go dormant (reduce resource usage). /// This is a hint; Arti may not fully support dormant mode yet. /// /// # Returns /// * 0 on success /// * -1 if not running #[no_mangle] pub extern "C" fn arti_go_dormant() -> c_int { if !IS_RUNNING.load(Ordering::SeqCst) { return -1; } // Arti doesn't have explicit dormant mode yet, but we can note the intent update_summary("Dormant"); 0 } /// Signal Arti to wake from dormant mode. /// /// # Returns /// * 0 on success /// * -1 if not running #[no_mangle] pub extern "C" fn arti_wake() -> c_int { if !IS_RUNNING.load(Ordering::SeqCst) { return -1; } update_summary("Active"); 0 } fn update_summary(s: &str) { if let Ok(mut guard) = BOOTSTRAP_SUMMARY.lock() { guard.clear(); guard.push_str(s); } } /// Main async entry point for Arti async fn run_arti( data_dir: PathBuf, socks_addr: SocketAddr, mut shutdown_rx: oneshot::Receiver<()>, ) -> Result<(), Box> { // Ensure data directory exists std::fs::create_dir_all(&data_dir)?; update_summary("Configuring..."); // Build Arti configuration with custom directories let cache_dir = data_dir.join("cache"); let state_dir = data_dir.join("state"); // Use from_directories which sets up storage correctly use arti_client::config::TorClientConfigBuilder; let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir) .build()?; update_summary("Bootstrapping..."); // Create and bootstrap the Tor client let client = TorClient::create_bootstrapped(config).await?; let client = Arc::new(client); // Store client reference for status queries if let Some(state) = ARTI_STATE.get() { if let Ok(mut guard) = state.lock() { guard.client = Some(client.clone()); } } // Mark bootstrap complete BOOTSTRAP_PROGRESS.store(100, Ordering::SeqCst); update_summary("Ready"); // Bind SOCKS listener let listener = TcpListener::bind(socks_addr).await?; tracing::info!("SOCKS5 proxy listening on {}", socks_addr); // Accept connections until shutdown loop { tokio::select! { accept_result = listener.accept() => { match accept_result { Ok((stream, peer_addr)) => { let client = client.clone(); tokio::spawn(async move { if let Err(e) = socks::handle_socks_connection(stream, peer_addr, client).await { tracing::debug!("SOCKS connection error from {}: {}", peer_addr, e); } }); } Err(e) => { tracing::warn!("Accept error: {}", e); } } } _ = &mut shutdown_rx => { tracing::info!("Shutdown signal received"); break; } } } update_summary("Shutting down..."); Ok(()) } ================================================ FILE: localPackages/Arti/arti-bitchat/src/socks.rs ================================================ //! SOCKS5 protocol handler for Arti //! //! Implements a minimal SOCKS5 server that forwards connections through Tor. use std::io; use std::net::SocketAddr; use std::sync::Arc; use arti_client::{TorClient, IntoTorAddr}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tor_rtcompat::PreferredRuntime; // SOCKS5 constants const SOCKS5_VERSION: u8 = 0x05; const SOCKS5_AUTH_NONE: u8 = 0x00; const SOCKS5_CMD_CONNECT: u8 = 0x01; const SOCKS5_ATYP_IPV4: u8 = 0x01; const SOCKS5_ATYP_DOMAIN: u8 = 0x03; const SOCKS5_ATYP_IPV6: u8 = 0x04; const SOCKS5_REP_SUCCESS: u8 = 0x00; const SOCKS5_REP_FAILURE: u8 = 0x01; const SOCKS5_REP_CONN_REFUSED: u8 = 0x05; /// Handle a single SOCKS5 connection pub async fn handle_socks_connection( mut stream: TcpStream, peer_addr: SocketAddr, client: Arc>, ) -> io::Result<()> { // --- Greeting --- // Client sends: VER | NMETHODS | METHODS let mut greeting = [0u8; 2]; stream.read_exact(&mut greeting).await?; if greeting[0] != SOCKS5_VERSION { return Err(io::Error::new( io::ErrorKind::InvalidData, "Not SOCKS5", )); } let nmethods = greeting[1] as usize; let mut methods = vec![0u8; nmethods]; stream.read_exact(&mut methods).await?; // We only support no-auth if !methods.contains(&SOCKS5_AUTH_NONE) { // Send failure: no acceptable methods stream.write_all(&[SOCKS5_VERSION, 0xFF]).await?; return Err(io::Error::new( io::ErrorKind::PermissionDenied, "No acceptable auth methods", )); } // Accept no-auth stream.write_all(&[SOCKS5_VERSION, SOCKS5_AUTH_NONE]).await?; // --- Request --- // Client sends: VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT let mut request_header = [0u8; 4]; stream.read_exact(&mut request_header).await?; if request_header[0] != SOCKS5_VERSION { return Err(io::Error::new( io::ErrorKind::InvalidData, "Invalid SOCKS5 request version", )); } let cmd = request_header[1]; let atyp = request_header[3]; if cmd != SOCKS5_CMD_CONNECT { // We only support CONNECT send_reply(&mut stream, SOCKS5_REP_FAILURE).await?; return Err(io::Error::new( io::ErrorKind::Unsupported, "Only CONNECT supported", )); } // Parse destination address let (dest_host, dest_port) = match atyp { SOCKS5_ATYP_IPV4 => { let mut addr = [0u8; 4]; stream.read_exact(&mut addr).await?; let mut port_buf = [0u8; 2]; stream.read_exact(&mut port_buf).await?; let port = u16::from_be_bytes(port_buf); let host = format!("{}.{}.{}.{}", addr[0], addr[1], addr[2], addr[3]); (host, port) } SOCKS5_ATYP_DOMAIN => { let mut len_buf = [0u8; 1]; stream.read_exact(&mut len_buf).await?; let len = len_buf[0] as usize; let mut domain = vec![0u8; len]; stream.read_exact(&mut domain).await?; let mut port_buf = [0u8; 2]; stream.read_exact(&mut port_buf).await?; let port = u16::from_be_bytes(port_buf); let host = String::from_utf8_lossy(&domain).to_string(); (host, port) } SOCKS5_ATYP_IPV6 => { let mut addr = [0u8; 16]; stream.read_exact(&mut addr).await?; let mut port_buf = [0u8; 2]; stream.read_exact(&mut port_buf).await?; let port = u16::from_be_bytes(port_buf); // Format IPv6 address let segments: Vec = addr .chunks(2) .map(|c| format!("{:02x}{:02x}", c[0], c[1])) .collect(); let host = format!("[{}]", segments.join(":")); (host, port) } _ => { send_reply(&mut stream, SOCKS5_REP_FAILURE).await?; return Err(io::Error::new( io::ErrorKind::InvalidData, "Unsupported address type", )); } }; tracing::debug!("SOCKS5 CONNECT from {} to {}:{}", peer_addr, dest_host, dest_port); // Connect through Tor let tor_addr = format!("{}:{}", dest_host, dest_port); let tor_addr = match tor_addr.as_str().into_tor_addr() { Ok(a) => a, Err(e) => { tracing::debug!("Invalid Tor address: {}", e); send_reply(&mut stream, SOCKS5_REP_FAILURE).await?; return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("Invalid Tor address: {}", e), )); } }; let tor_stream = match client.connect(tor_addr).await { Ok(s) => s, Err(e) => { tracing::debug!("Tor connect failed: {}", e); send_reply(&mut stream, SOCKS5_REP_CONN_REFUSED).await?; return Err(io::Error::new( io::ErrorKind::ConnectionRefused, e.to_string(), )); } }; // Send success reply // Reply: VER | REP | RSV | ATYP | BND.ADDR | BND.PORT // We use 0.0.0.0:0 as the bound address since we're proxying let reply = [ SOCKS5_VERSION, SOCKS5_REP_SUCCESS, 0x00, // RSV SOCKS5_ATYP_IPV4, 0, 0, 0, 0, // BND.ADDR 0, 0, // BND.PORT ]; stream.write_all(&reply).await?; // Bidirectional copy let (mut client_read, mut client_write) = stream.into_split(); let (mut tor_read, mut tor_write) = tor_stream.split(); let client_to_tor = async { tokio::io::copy(&mut client_read, &mut tor_write).await }; let tor_to_client = async { tokio::io::copy(&mut tor_read, &mut client_write).await }; tokio::select! { result = client_to_tor => { if let Err(e) = result { tracing::debug!("Client to Tor copy error: {}", e); } } result = tor_to_client => { if let Err(e) = result { tracing::debug!("Tor to client copy error: {}", e); } } } Ok(()) } async fn send_reply(stream: &mut TcpStream, rep: u8) -> io::Result<()> { let reply = [ SOCKS5_VERSION, rep, 0x00, // RSV SOCKS5_ATYP_IPV4, 0, 0, 0, 0, // BND.ADDR 0, 0, // BND.PORT ]; stream.write_all(&reply).await } ================================================ FILE: localPackages/Arti/build-ios.sh ================================================ #!/bin/bash # # Build arti-bitchat for iOS/macOS with aggressive size optimization # # Output: Frameworks/arti.xcframework containing static libraries for: # - aarch64-apple-ios (iOS device) # - aarch64-apple-ios-sim (iOS simulator, Apple Silicon) # - x86_64-apple-ios (iOS simulator, Intel - optional) # - aarch64-apple-darwin (macOS) # set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" # Configuration CRATE_NAME="arti-bitchat" LIB_NAME="libarti_bitchat.a" FRAMEWORK_NAME="arti" OUTPUT_DIR="$SCRIPT_DIR/Frameworks" # Targets to build TARGETS=( "aarch64-apple-ios" # iOS device "aarch64-apple-ios-sim" # iOS simulator (Apple Silicon) "aarch64-apple-darwin" # macOS ) # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } # Check prerequisites check_prerequisites() { log_info "Checking prerequisites..." if ! command -v rustc &> /dev/null; then log_error "Rust is not installed. Please install via rustup." exit 1 fi if ! command -v cargo &> /dev/null; then log_error "Cargo is not installed. Please install via rustup." exit 1 fi # Check/install targets for target in "${TARGETS[@]}"; do if ! rustup target list --installed | grep -q "$target"; then log_info "Installing target: $target" rustup target add "$target" fi done # Install cbindgen if needed if ! command -v cbindgen &> /dev/null; then log_info "Installing cbindgen..." cargo install cbindgen fi log_info "Prerequisites OK" } # Set up aggressive size optimization flags and deployment targets setup_rustflags() { local target="$1" # Base flags for size optimization export RUSTFLAGS="-C opt-level=z -C lto=fat -C codegen-units=1 -C panic=abort -C strip=symbols" # Set deployment targets to suppress linker warnings about version mismatches case "$target" in *-apple-ios-sim*) export IPHONEOS_DEPLOYMENT_TARGET="16.0" # Simulator uses iPhone SDK but needs the sim target ;; *-apple-ios*) export IPHONEOS_DEPLOYMENT_TARGET="16.0" ;; *-apple-darwin*) export MACOSX_DEPLOYMENT_TARGET="13.0" ;; esac log_info "RUSTFLAGS: $RUSTFLAGS" log_info "Deployment target: MACOSX=$MACOSX_DEPLOYMENT_TARGET IPHONEOS=$IPHONEOS_DEPLOYMENT_TARGET" } # Build for a single target build_target() { local target="$1" log_info "Building for target: $target" setup_rustflags "$target" # Build release cargo build --release --target "$target" -p "$CRATE_NAME" # Check output local lib_path="target/$target/release/$LIB_NAME" if [[ -f "$lib_path" ]]; then local size=$(du -h "$lib_path" | cut -f1) log_info "Built $lib_path ($size)" else log_error "Build failed: $lib_path not found" exit 1 fi } # Create xcframework from built libraries create_xcframework() { log_info "Creating xcframework..." local xcframework_path="$OUTPUT_DIR/$FRAMEWORK_NAME.xcframework" # Remove existing xcframework rm -rf "$xcframework_path" mkdir -p "$OUTPUT_DIR" # Build the xcodebuild command local cmd="xcodebuild -create-xcframework" for target in "${TARGETS[@]}"; do local lib_path="$SCRIPT_DIR/target/$target/release/$LIB_NAME" if [[ -f "$lib_path" ]]; then # Strip the library for additional size reduction log_info "Stripping $target library..." strip -x "$lib_path" 2>/dev/null || true cmd="$cmd -library $lib_path" # Add headers if they exist local header_dir="$OUTPUT_DIR/include" if [[ -d "$header_dir" ]]; then cmd="$cmd -headers $header_dir" fi else log_warn "Skipping missing library: $lib_path" fi done cmd="$cmd -output $xcframework_path" log_info "Running: $cmd" eval "$cmd" if [[ -d "$xcframework_path" ]]; then local size=$(du -sh "$xcframework_path" | cut -f1) log_info "Created $xcframework_path ($size)" else log_error "Failed to create xcframework" exit 1 fi } # Generate C header using cbindgen generate_header() { log_info "Generating C header..." local header_dir="$OUTPUT_DIR/include" local header_path="$header_dir/arti.h" mkdir -p "$header_dir" # Create cbindgen.toml if it doesn't exist if [[ ! -f "$CRATE_NAME/cbindgen.toml" ]]; then cat > "$CRATE_NAME/cbindgen.toml" << 'EOF' language = "C" include_guard = "ARTI_H" no_includes = true sys_includes = ["stdint.h", "stdbool.h"] [export] include = ["arti_start", "arti_stop", "arti_is_running", "arti_bootstrap_progress", "arti_bootstrap_summary", "arti_go_dormant", "arti_wake"] [fn] args = "Auto" [parse] parse_deps = false EOF fi cbindgen --config "$CRATE_NAME/cbindgen.toml" \ --crate "$CRATE_NAME" \ --output "$header_path" if [[ -f "$header_path" ]]; then log_info "Generated $header_path" cat "$header_path" else log_warn "cbindgen did not generate header, creating manually..." # Fallback: create header manually cat > "$header_path" << 'EOF' #ifndef ARTI_H #define ARTI_H #include #ifdef __cplusplus extern "C" { #endif /** * Start Arti with a SOCKS5 proxy. * * @param data_dir Path to data directory for Tor state (C string) * @param socks_port Port for SOCKS5 proxy (e.g., 39050) * @return 0 on success, negative on error */ int32_t arti_start(const char *data_dir, uint16_t socks_port); /** * Stop Arti gracefully. * * @return 0 on success, -1 if not running */ int32_t arti_stop(void); /** * Check if Arti is currently running. * * @return 1 if running, 0 if not running */ int32_t arti_is_running(void); /** * Get the current bootstrap progress (0-100). * * @return Progress percentage */ int32_t arti_bootstrap_progress(void); /** * Get the current bootstrap summary string. * * @param buf Buffer to write the summary into * @param len Length of the buffer * @return Number of bytes written, -1 on error */ int32_t arti_bootstrap_summary(char *buf, int32_t len); /** * Signal Arti to go dormant (reduce resource usage). * * @return 0 on success, -1 if not running */ int32_t arti_go_dormant(void); /** * Signal Arti to wake from dormant mode. * * @return 0 on success, -1 if not running */ int32_t arti_wake(void); #ifdef __cplusplus } #endif #endif /* ARTI_H */ EOF log_info "Created manual header at $header_path" fi } # Print size report print_size_report() { log_info "=== Size Report ===" for target in "${TARGETS[@]}"; do local lib_path="$SCRIPT_DIR/target/$target/release/$LIB_NAME" if [[ -f "$lib_path" ]]; then local size=$(du -h "$lib_path" | cut -f1) echo " $target: $size" fi done local xcframework_path="$OUTPUT_DIR/$FRAMEWORK_NAME.xcframework" if [[ -d "$xcframework_path" ]]; then local total_size=$(du -sh "$xcframework_path" | cut -f1) echo " xcframework total: $total_size" fi } # Main main() { log_info "Building arti-bitchat for iOS/macOS" log_info "==================================" check_prerequisites generate_header for target in "${TARGETS[@]}"; do build_target "$target" done create_xcframework print_size_report log_info "Build complete!" log_info "xcframework: $OUTPUT_DIR/$FRAMEWORK_NAME.xcframework" } # Run main "$@" ================================================ FILE: localPackages/BitLogger/Package.swift ================================================ // swift-tools-version: 5.9 import PackageDescription let package = Package( name: "BitLogger", platforms: [ .iOS(.v16), .macOS(.v13) ], products: [ .library( name: "BitLogger", targets: ["BitLogger"] ) ], targets: [ .target( name: "BitLogger", path: "Sources" ), .testTarget( name: "BitLoggerTests", dependencies: ["BitLogger"] ) ] ) ================================================ FILE: localPackages/BitLogger/Sources/OSLog+Categories.swift ================================================ // // OSLog+Categories.swift // BitLogger // // This is free and unencumbered software released into the public domain. // For more information, see // #if canImport(os.log) import os.log #endif public extension OSLog { private static let subsystem = "chat.bitchat" static let noise = OSLog(subsystem: subsystem, category: "noise") static let encryption = OSLog(subsystem: subsystem, category: "encryption") static let keychain = OSLog(subsystem: subsystem, category: "keychain") static let session = OSLog(subsystem: subsystem, category: "session") static let security = OSLog(subsystem: subsystem, category: "security") static let handshake = OSLog(subsystem: subsystem, category: "handshake") static let sync = OSLog(subsystem: subsystem, category: "sync") } ================================================ FILE: localPackages/BitLogger/Sources/SecureLogger.swift ================================================ // // SecureLogger.swift // BitLogger // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation #if canImport(os.log) import os.log #else public struct OSLog { public let subsystem: String public let category: String public init(subsystem: String, category: String) { self.subsystem = subsystem self.category = category } } public struct OSLogType: CustomStringConvertible { private let label: String private init(_ label: String) { self.label = label } public var description: String { label } public static let debug = OSLogType("debug") public static let info = OSLogType("info") public static let `default` = OSLogType("default") public static let error = OSLogType("error") public static let fault = OSLogType("fault") } @usableFromInline let secureLoggerFallbackFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter }() @usableFromInline func os_log(_ message: StaticString, log: OSLog, type: OSLogType, _ args: CVarArg...) { let rawFormat = String(describing: message) let format = rawFormat .replacingOccurrences(of: "%{public}@", with: "%@") .replacingOccurrences(of: "%{private}@", with: "%@") let formatted = String(format: format, arguments: args) let timestamp = secureLoggerFallbackFormatter.string(from: Date()) print("[\(timestamp)] [\(log.subsystem)::\(log.category)] [\(type.description)] \(formatted)") } #endif /// Centralized security-aware logging framework /// Provides safe logging that filters sensitive data and security events public final class SecureLogger { // MARK: - Timestamp Formatter private static let timestampFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss.SSS" formatter.timeZone = TimeZone.current return formatter }() // MARK: - Log Levels enum LogLevel { case debug case info case warning case error case fault fileprivate var order: Int { switch self { case .debug: return 0 case .info: return 1 case .warning: return 2 case .error: return 3 case .fault: return 4 } } var osLogType: OSLogType { switch self { case .debug: return .debug case .info: return .info case .warning: return .default case .error: return .error case .fault: return .fault } } } // MARK: - Global Threshold /// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL. private static let minimumLevel: LogLevel = { let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased() switch env { case "debug": return .debug case "warning": return .warning case "error": return .error case "fault": return .fault default: return .info } }() private static func shouldLog(_ level: LogLevel) -> Bool { return level.order >= minimumLevel.order } } // MARK: - Public Logging Methods public extension SecureLogger { static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { log(message(), category: category, level: .debug, file: file, line: line, function: function) } static func info(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { log(message(), category: category, level: .info, file: file, line: line, function: function) } static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { log(message(), category: category, level: .warning, file: file, line: line, function: function) } static func error(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { log(message(), category: category, level: .error, file: file, line: line, function: function) } /// Log errors with context static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { let location = formatLocation(file: file, line: line, function: function) let sanitized = context().sanitized() let errorDesc = error.localizedDescription.sanitized() #if DEBUG os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc) #else os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc) #endif } } // MARK: Security Event Logging public extension SecureLogger { enum SecurityEvent { case handshakeStarted(peerID: String) case handshakeCompleted(peerID: String) case handshakeFailed(peerID: String, error: String) case sessionExpired(peerID: String) case authenticationFailed(peerID: String) var message: String { switch self { case .handshakeStarted(let peerID): return "Handshake started with peer: \(peerID.sanitized())" case .handshakeCompleted(let peerID): return "Handshake completed with peer: \(peerID.sanitized())" case .handshakeFailed(let peerID, let error): return "Handshake failed with peer: \(peerID.sanitized()), error: \(error)" case .sessionExpired(let peerID): return "Session expired for peer: \(peerID.sanitized())" case .authenticationFailed(let peerID): return "Authentication failed for peer: \(peerID.sanitized())" } } } static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) { logSecurityEvent(event, level: .debug, file: file, line: line, function: function) } static func info(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) { logSecurityEvent(event, level: .info, file: file, line: line, function: function) } static func warning(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) { logSecurityEvent(event, level: .warning, file: file, line: line, function: function) } static func error(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) { logSecurityEvent(event, level: .error, file: file, line: line, function: function) } } // MARK: - Convenience Extensions public extension SecureLogger { enum KeyOperation: String, CustomStringConvertible { case load case create case generate case delete case save public var description: String { rawValue } } /// Log key management operations static func logKeyOperation(_ operation: KeyOperation, keyType: String, success: Bool = true, file: String = #file, line: Int = #line, function: String = #function) { if success { debug("Key operation '\(operation)' for \(keyType) succeeded", category: .keychain, file: file, line: line, function: function) } else { error("Key operation '\(operation)' for \(keyType) failed", category: .keychain, file: file, line: line, function: function) } } } // MARK: - Private Helpers private extension SecureLogger { /// Log general messages with automatic sensitive data filtering static func log(_ message: @autoclosure () -> String, category: OSLog, level: LogLevel, file: String, line: Int, function: String) { guard shouldLog(level) else { return } let location = formatLocation(file: file, line: line, function: function) let sanitized = "\(location) \(message())".sanitized() #if DEBUG os_log("%{public}@", log: category, type: level.osLogType, sanitized) #else // In release builds, only log non-debug messages if level != .debug { os_log("%{private}@", log: category, type: level.osLogType, sanitized) } #endif } /// Log a security event static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info, file: String, line: Int, function: String) { guard shouldLog(level) else { return } let location = formatLocation(file: file, line: line, function: function) let message = "\(location) \(event.message)" #if DEBUG os_log("%{public}@", log: .security, type: level.osLogType, message) #else // In release, use private logging to prevent sensitive data exposure os_log("%{private}@", log: .security, type: level.osLogType, message) #endif } /// Format location information for logging static func formatLocation(file: String, line: Int, function: String) -> String { let fileName = (file as NSString).lastPathComponent let timestamp = timestampFormatter.string(from: Date()) return "[\(timestamp)] [\(fileName):\(line) \(function)]" } } // MARK: - Migration Helper /// Helper to migrate from print statements to SecureLogger /// Usage: Replace print(...) with secureLog(...) public func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\n", file: String = #file, line: Int = #line, function: String = #function) { #if DEBUG let message = items.map { String(describing: $0) }.joined(separator: separator) SecureLogger.debug(message, file: file, line: line, function: function) #endif } ================================================ FILE: localPackages/BitLogger/Sources/String+Sanitization.swift ================================================ // // String+Sanitization.swift // BitLogger // // This is free and unencumbered software released into the public domain. // For more information, see // import Foundation extension String { /// Sanitize strings to remove potentially sensitive data func sanitized() -> String { let key = self as NSString // Check cache first if let cached = Self.queue.sync(execute: { Self.cache.object(forKey: key) }) { return cached as String } var sanitized = self // Remove full fingerprints (keep first 8 chars for debugging) let fingerprintPattern = #/[a-fA-F0-9]{64}/# sanitized = sanitized.replacing(fingerprintPattern) { match in let fingerprint = String(match.output) return String(fingerprint.prefix(8)) + "..." } // Remove base64 encoded data that might be keys let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/# sanitized = sanitized.replacing(base64Pattern) { _ in "" } // Remove potential passwords (assuming they're in quotes or after "password:") let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/# sanitized = sanitized.replacing(passwordPattern) { _ in "password: " } // Truncate peer IDs to first 8 characters let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/# sanitized = sanitized.replacing(peerIDPattern) { match in "peerID: \(match.1)..." } // Cache the result Self.queue.sync { Self.cache.setObject(sanitized as NSString, forKey: key) } return sanitized } } // MARK: - Cache Helpers private extension String { static let queue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent) static let cache: NSCache = { let cache = NSCache() cache.countLimit = 100 // Keep last 100 sanitized strings return cache }() } ================================================ FILE: localPackages/BitLogger/Tests/StringSanitizationTests.swift ================================================ // // StringSanitizationTests.swift // BitLogger // // Created by Islam on 19/10/2025. // import Testing @testable import BitLogger struct StringSanitizationTests { @Test("64-hex fingerprint is truncated to first 8 chars followed by ellipsis") func fingerprintTruncation() async throws { let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" #expect(fingerprint.count == 64) let input = "fingerprint=\(fingerprint)" let output = input.sanitized() #expect(output.contains("fingerprint=01234567...")) // Ensure no full fingerprint remains #expect(output.contains(fingerprint) == false) } @Test("Multiple fingerprints in a string are all truncated") func multipleFingerprintTruncation() async throws { let fp1 = String(repeating: "a", count: 64) let fp2 = String(repeating: "b", count: 64) let input = "fp1=\(fp1) fp2=\(fp2)" let output = input.sanitized() #expect(output.contains("fp1=aaaaaaaa...")) #expect(output.contains("fp2=bbbbbbbb...")) #expect(output.contains(fp1) == false) #expect(output.contains(fp2) == false) } @Test("Base64-like long data is replaced with ") func base64Replacement() async throws { // 44+ chars of base64 characters let base64ish = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2NTQzMjE=" let input = "payload=\(base64ish)" let output = input.sanitized() #expect(output == "payload=") } @Test("Base64-like without padding is replaced with ") func base64NoPaddingReplacement() async throws { let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" #expect(base64ish.count >= 40) let input = "b64:\(base64ish)" let output = input.sanitized() #expect(output == "b64:") } @Test("Short base64-like strings (below threshold) are not replaced") func shortBase64NotReplaced() async throws { let short = "QUJDREVGR0hJSktMTU5P" // < 40 chars let input = "payload=\(short)" let output = input.sanitized() #expect(output == input) } @Test("Password redaction for key:value formats", arguments: [ "password: secret123", "password=secret123", "password = secret123", "password: 'secret123'", "password:\"secret123\"", "password='secret123'" ]) func passwordRedactionKeyValue(password: String) async throws { #expect(password.sanitized() == "password: ") } @Test("Password redaction inside wider messages") func passwordRedactionInContext() async throws { let input = "user=john password: 'p@ssW0rd' attempt=1" let output = input.sanitized() #expect(output == "user=john password: attempt=1") } @Test("PeerID is truncated to first 8 chars followed by ellipsis") func peerIDTruncation() async throws { let peer = "ABCDEF12GHIJKL34" let input = "peerID: \(peer)" let output = input.sanitized() #expect(output == "peerID: ABCDEF12...") } @Test("PeerID not truncated when exactly 8 chars") func peerIDExactlyEightNotTruncated() async throws { let peer = "ABCDEF12" let input = "peerID: \(peer)" let output = input.sanitized() // Pattern only matches when there are more than 8 trailing chars, so unchanged #expect(output == input) } @Test("Non-matching content remains unchanged") func nonMatchingUnchanged() async throws { let input = "Hello world 123 - nothing sensitive here." let output = input.sanitized() #expect(output == input) } @Test("Idempotency: sanitizing twice yields same result") func idempotentSanitization() async throws { let input = """ fingerprint=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \ password: "superSecret" \ peerID: ZYXWVUT987654321 \ payload=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ """ let once = input.sanitized() let twice = once.sanitized() #expect(once == twice) } @Test("Mixed content: all rules apply in a single string") func mixedContent() async throws { let fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" let peer = "PEERID01EXTRA" let input = "fp=\(fingerprint) password='x' peerID: \(peer) data=\(base64ish)" let output = input.sanitized() #expect(output.contains("fp=fedcba98...")) #expect(output.contains("password: ")) #expect(output.contains("peerID: PEERID01...")) #expect(output.contains("data=")) #expect(output.contains(fingerprint) == false) #expect(output.contains(base64ish) == false) } @Test("Cache returns consistent result for repeated inputs") func cacheHitConsistency() async throws { let input = "password: hunter2" let first = input.sanitized() let second = input.sanitized() #expect(first == "password: ") #expect(first == second) } } ================================================ FILE: relays/online_relays_gps.csv ================================================ Relay URL,Latitude,Longitude relay-rpi.edufeed.org,49.4521,11.0767 canteen-hankering.space,40.8302,-74.1299 inbox.scuba323.com,40.8218,-74.45 tenex.chat,50.4754,12.3683 nostr.blankfors.se,60.1699,24.9384 relay.homeinhk.xyz,45.5152,-122.678 soloco.nl,43.6532,-79.3832 nostr.okienko.live,50.1109,8.68213 relay.olas.app,50.4754,12.3683 premium.primal.net,43.6532,-79.3832 nostr.overmind.lol,43.6532,-79.3832 nostr.carroarmato0.be,51.0368,3.21186 nostr.islandarea.net,35.4669,-97.6473 relay.routstr.com,43.6532,-79.3832 relay.nostraddress.com,43.6532,-79.3832 relay.og.coop,43.6532,-79.3832 nostr.robosats.org,64.1476,-21.9392 nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 nostr2.girino.org,43.6532,-79.3832 relay.primal.net,43.6532,-79.3832 santo.iguanatech.net,40.8302,-74.1299 nostr.tadryanom.me,43.6532,-79.3832 nostr.mifen.me,43.6532,-79.3832 relay.cyphernomad.com,60.4032,25.0321 relay.nostr.place,32.7767,-96.797 testr.nymble.world,40.8054,-74.0241 wot.nostr.place,32.7767,-96.797 strfry.atlantislabs.space,43.6532,-79.3832 relay.flashapp.me,43.652,-79.3633 npub1spxdug4m3y24hpx5crm0el4zhkk0wafs8kp6m0xu0wecygqej2xqq8gyhx.fips.network,43.6532,-79.3832 nostr-relay.cbrx.io,43.6532,-79.3832 nostr.nodesmap.com,59.3327,18.0656 relay.bithome.site,52.3563,4.95714 nostr.girino.org,43.6532,-79.3832 nostr-03.dorafactory.org,1.35208,103.82 nostrcheck.tnsor.network,43.6532,-79.3832 strfry.apps3.slidestr.net,40.4167,-3.70329 relay.bitcoindistrict.org,43.6532,-79.3832 plebchain.club,43.6532,-79.3832 srtrelay.c-stellar.net,43.6532,-79.3832 nostr-relay.nextblockvending.com,47.2343,-119.853 nostr-relay.corb.net,38.8353,-104.822 r.0kb.io,32.789,-96.7989 relay.malxte.de,52.52,13.405 relay.nostrzh.org,43.6532,-79.3832 relay.xavierdamman.com,49.4543,11.0746 relay.anmore.me,49.281,-123.117 relay.vantis.ninja,43.6532,-79.3832 relay-dev.satlantis.io,40.8302,-74.1299 relay.snort.social,53.3498,-6.26031 relay.gulugulu.moe,43.6532,-79.3832 relay.wavlake.com,41.2619,-95.8608 testrelay.era21.space,43.6532,-79.3832 relay.wavefunc.live,39.7392,-104.99 nostr-kyomu-haskell.onrender.com,37.7775,-122.397 relay.bornheimer.app,50.1109,8.68213 relayone.soundhsa.com,39.1008,-94.5811 relay.nostrcheck.me,43.6532,-79.3832 nostr.azzamo.net,52.2633,21.0283 relay.zone667.com,60.1699,24.9384 slick.mjex.me,39.0418,-77.4744 nostr-relay.zeabur.app,25.0797,121.234 relay.opensourcevillage.org,49.4543,11.0746 relay.bao.network,49.4543,11.0746 pyramid.cult.cash,32.9483,-96.7299 nostr.oxtr.dev,50.4754,12.3683 kotukonostr.onrender.com,37.7775,-122.397 relayrs.notoshi.win,43.6532,-79.3832 nostr.stakey.net,52.3676,4.90414 relay.0xchat.com,43.6532,-79.3832 nostr.luisschwab.net,43.6532,-79.3832 relay.lacompagniemaximus.com,45.3147,-73.8785 purplerelay.com,43.6532,-79.3832 relay.btcforplebs.com,43.6532,-79.3832 nostrelites.org,41.8781,-87.6298 relay.upleb.uk,51.9194,19.1451 relay.libernet.app,43.6532,-79.3832 relay.mostro.network,40.8302,-74.1299 strfry.elswa-dev.online,50.1109,8.68213 relay.agorist.space,52.3734,4.89406 nostr.notribe.net,40.8302,-74.1299 relay-freeharmonypeople.space,38.7223,-9.13934 nostr.thalheim.io,60.1699,24.9384 relay.erybody.com,41.4513,-81.7021 relay.layer.systems,49.0291,8.35695 chat-relay.zap-work.com,43.6532,-79.3832 nostr.noones.com,50.1109,8.68213 relay.orangepill.ovh,49.1689,-0.358841 nostr.defucc.me,50.1109,8.68213 nostrelay.circum.space,52.3676,4.90414 public.crostr.com,43.6532,-79.3832 relay.openfarmtools.org,60.1699,24.9384 offchain.pub,39.1585,-94.5728 nestr.nedao.ch,47.0151,6.98832 relay.evanverma.com,40.8302,-74.1299 testnet-relay.samt.st,40.8302,-74.1299 relay-nl.zombi.cloudrodion.com,50.8943,6.06237 nostr-relay.xbytez.io,50.6924,3.20113 nostr-pub.wellorder.net,45.5201,-122.99 strfry.shock.network,39.0438,-77.4874 relay-testnet.k8s.layer3.news,37.3387,-121.885 nostr.chrissexton.org,43.6532,-79.3832 adre.su,59.9311,30.3609 nostr.jerrynya.fun,31.2304,121.474 nrs-01.darkcloudarcade.com,39.1008,-94.5811 nos.lol,50.4754,12.3683 relay.nostr.wirednet.jp,34.706,135.493 relay.agora.social,50.7383,15.0648 r.bitcoinhold.net,43.6532,-79.3832 wot.codingarena.top,50.4754,12.3683 purpura.cloud,43.6532,-79.3832 relay02.lnfi.network,35.6764,139.65 relay.nostriot.com,41.5695,-83.9786 nostr.spaceshell.xyz,43.6532,-79.3832 fenrir-s.notoshi.win,43.6532,-79.3832 x.kojira.io,43.6532,-79.3832 relay.nostrdice.com,-33.8688,151.209 nostr.nadajnik.org,50.1109,8.68213 freelay.sovbit.host,64.1476,-21.9392 nostr.spicyz.io,43.6532,-79.3832 bucket.coracle.social,37.7775,-122.397 relay.fundstr.me,42.3601,-71.0589 nostr.wom.wtf,43.6532,-79.3832 relay.henryxplace.eu.org:9988,31.2304,121.474 taxation-capable-cards-takes.trycloudflare.com,43.6532,-79.3832 relay.mmwaves.de,48.8575,2.35138 relay.guggero.org,46.0037,8.95105 relay5.bitransfer.org,43.6532,-79.3832 nostrue.com,40.8054,-74.0241 nostr.night7.space,50.4754,12.3683 relay.endfiat.money,43.6532,-79.3832 nostr.0cx.de,49.029,8.35695 temp.iris.to,43.6532,-79.3832 relay.internationalright-wing.org,-22.5022,-48.7114 relay.staging.commonshub.brussels,49.4543,11.0746 relay.bitmacro.io,48.8566,2.35222 nostr.rblb.it,43.6532,-79.3832 discovery.us.nostria.app,52.3676,4.90414 relay.binaryrobot.com,43.6532,-79.3832 relay.islandbitcoin.com,12.8498,77.6545 relay-fra.zombi.cloudrodion.com,48.8566,2.35222 relay.nostrverse.net,43.6532,-79.3832 relay.snotr.nl:49999,52.0195,4.42946 schnorr.me,43.6532,-79.3832 prl.plus,55.7628,37.5983 relay.wayback.st,52.3676,4.90414 0x-nostr-relay.fly.dev,48.8575,2.35138 relay.vrtmrz.net,43.6532,-79.3832 nostr.rtvslawenia.com,49.4543,11.0746 relay.bitmacro.cloud,43.6532,-79.3832 relay2.ngengine.org,43.6532,-79.3832 nostr.4rs.nl,49.0291,8.35696 nostr.self-determined.de,53.5,10.25 relay.decentnewsroom.com,50.4754,12.3683 nostr.faultables.net,43.6532,-79.3832 bcast.seutoba.com.br,43.6532,-79.3832 relay.credenso.cafe,43.3601,-80.3127 ribo.us.nostria.app,41.5868,-93.625 top.testrelay.top,43.6532,-79.3832 wot.shaving.kiwi,43.6532,-79.3832 nostr.liberty.fans,36.9104,-89.5875 orly.musiquay.org,43.6532,-79.3832 relay.jeffg.fyi,43.6532,-79.3832 relay.ditto.pub,43.6532,-79.3832 nostr.bitcoiner.social,47.6743,-117.112 nostrcity-club.fly.dev,48.8575,2.35138 nostr.dpinkerton.com,39.1008,-94.5811 relay.dwadziesciajeden.pl,52.2297,21.0122 nostr-verified.wellorder.net,45.5201,-122.99 kanagrovv-pyramid.kozow.com,43.4305,-83.9638 nostr.na.social,43.6532,-79.3832 theoutpost.life,64.1476,-21.9392 relay.wellorder.net,45.5201,-122.99 api.freefrom.space/v1/ws,43.6532,-79.3832 relay.nostr-check.me,43.6532,-79.3832 v-relay.d02.vrtmrz.net,34.6937,135.502 relay.puresignal.news,43.6532,-79.3832 relay.satlantis.io,32.8769,-80.0114 bitcoiner.social,47.6743,-117.112 relay2.angor.io,48.1046,11.6002 relay-dev.gulugulu.moe,43.6532,-79.3832 wot.sudocarlos.com,43.6532,-79.3832 relay.angor.io,48.1046,11.6002 articles.layer3.news,37.3387,-121.885 inbox.azzamo.net,45.3147,-73.8785 nostr.agentcampfire.com,52.3676,4.90414 nostr.myshosholoza.co.za,52.3913,4.66545 myvoiceourstory.org,37.3598,-121.981 relay.seq1.net,43.6532,-79.3832 relay.minibolt.info,43.6532,-79.3832 nostr.mom,50.4754,12.3683 relay.bullishbounty.com,43.6532,-79.3832 relay.bnos.space,43.6532,-79.3832 nostr.2b9t.xyz,34.0549,-118.243 pyramid.aaro.cc,34.0549,-118.243 strfry.ymir.cloud,34.0965,-117.585 nostr.quali.chat,60.1699,24.9384 relay.bitmacro.pro,43.6532,-79.3832 discovery.eu.nostria.app,52.3676,4.90414 yabu.me,35.6092,139.73 nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 relay.nostu.be,40.4167,-3.70329 nostr.zoracle.org,45.6018,-121.185 ribo.eu.nostria.app,52.3676,4.90414 relay.nostrhub.fr,48.1045,11.6004 nostr.data.haus,50.4754,12.3683 relay.spacetomatoes.net,42.3601,-71.0589 relay.satnam.pub,43.6532,-79.3832 aaa-api.freefrom.space/v1/ws,43.6532,-79.3832 nostr.ps1829.com,33.8851,130.883 nostr.vulpem.com,49.4543,11.0746 relay.ngengine.org,43.6532,-79.3832 nostr.snowbla.de,60.1699,24.9384 nostr.bitczat.pl,60.1699,24.9384 relay.lightning.pub,39.0438,-77.4874 bitchat.nostr1.com,38.6327,-90.1961 nostr.chaima.info,50.1109,8.68213 strfry.openhoofd.nl,51.9229,4.40833 espelho.girino.org,43.6532,-79.3832 bcast.girino.org,43.6532,-79.3832 relay.nostr.net,43.6532,-79.3832 r.alphaama.com,60.1699,24.9384 relay.lanacoin-eternity.com,40.8302,-74.1299 relay.satmaxt.xyz,43.6532,-79.3832 relayone.geektank.ai,39.1008,-94.5811 nostr.ovia.to,43.6532,-79.3832 satsage.xyz,37.3986,-121.964 relay.cathouse-propeller.com,52.3676,4.90414 nostr-01.yakihonne.com,1.29524,103.79 lightning.red,53.3498,-6.26031 relay.ru.ac.th,13.7607,100.627 relay.qstr.app,51.5072,-0.127586 relay.mitchelltribe.com,39.0438,-77.4874 relay.purplefrog.cloud,35.6916,139.768 relay.commonshub.brussels,49.4543,11.0746 nostr-relay.psfoundation.info,39.0438,-77.4874 relay.laantungir.net,-19.4692,-42.5315 librerelay.aaroniumii.com,43.6532,-79.3832 relay.jabato.space,52.6907,4.8181 okn.czas.plus,50.1109,8.68213 relay.fountain.fm,43.6532,-79.3832 wot.brightbolt.net,47.6732,-117.239 khatru.nostrver.se,51.1792,5.89444 nostr-relay.zimage.com,34.0549,-118.243 spookstr2.nostr1.com,38.6327,-90.1961 nostr.simplex.icu,50.1109,8.68213 relay.getsafebox.app,43.6532,-79.3832 nostr.mikoshi.de,51.2821,6.78285 nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 relay.klabo.world,47.674,-122.122 wot.dtonon.com,43.6532,-79.3832 nostr.n7ekb.net,36.1527,-95.9902 relay.edufeed.org,49.4521,11.0767 relay.samt.st,40.8302,-74.1299 relay.chorus.community,48.5333,10.7 nostr-relay.online,43.6532,-79.3832 nostr.red5d.dev,43.6532,-79.3832 relay.nosto.re,51.1792,5.89444 vault.iris.to,43.6532,-79.3832 nostr-2.21crypto.ch,47.5356,8.73209 nostr.sathoarder.com,48.5734,7.75211 relay.damus.io,43.6532,-79.3832 nostr-relay.amethyst.name,39.0067,-77.4291 relay.illuminodes.com,47.6062,-122.332 memlay.v0l.io,53.3498,-6.26031 testorly.nosfabrica.com,37.3986,-121.964 relay.toastr.net,40.8054,-74.0241 wot.nostr.net,43.6532,-79.3832 relay.contextvm.org,53.3498,-6.26031 nostr.huszonegy.world,47.4979,19.0402 relay.redsh1ft.com,33.6129,-111.915 relay.sigit.io,50.4754,12.3683 nostr-dev.wellorder.net,45.5201,-122.99 relay.mostr.pub,43.6532,-79.3832 relay.sharegap.net,43.6532,-79.3832 wot.nostr.party,36.1627,-86.7816 strfry.bonsai.com,37.8716,-122.273 relay.visionfusen.org,43.6532,-79.3832 treuzkas.branruz.com,48.8575,2.35138 relay.degmods.com,50.4754,12.3683 relay.cypherflow.ai,48.8575,2.35138 pyramid.self-determined.de,53.5,10.25 relay.cosmicbolt.net,37.3986,-121.964 fanfares.nostr1.com,38.6327,-90.1961 orly-relay.imwald.eu,48.8575,2.35138