[
  {
    "path": ".gitattributes",
    "content": "# Prevent Github Languages stats skewing:\n\n# Binaries and assets\n**/*.xcframework/** linguist-vendored\n**/*.xcassets/** linguist-vendored\n\n# Generated files\n**/*.pbxproj linguist-generated\n**/*.storyboard linguist-generated\nPackage.resolved linguist-generated\n\n# Downloaded CSVs\nrelays/online_relays_gps.csv linguist-vendored\n\n# Docs\n**/*.md linguist-documentation\n\n# Configs\nConfigs/*.xcconfig linguist-documentation\n**/*.plist linguist-documentation\n"
  },
  {
    "path": ".github/workflows/fetch_georelays.yml",
    "content": "name: Fetch GeoRelays Data\n\non:\n  schedule:\n    - cron: '0 6 * * 0'\n  workflow_dispatch:\n\npermissions:\n  contents: write\n  pull-requests: write\n\njobs:\n  update-relay-data:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          fetch-depth: 0\n\n      - name: Fetch GeoRelays\n        run: |\n          wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv\n          mv nostr_relays.csv ./relays/online_relays_gps.csv\n\n      - name: Check for changes\n        id: git-check\n        run: |\n          git diff --exit-code || echo \"changes=true\" >> $GITHUB_OUTPUT\n    \n      - name: Commit and push changes\n        if: steps.git-check.outputs.changes == 'true'\n        run: |\n          git config --local user.email \"action@github.com\"\n          git config --local user.name \"GitHub Action\"\n          git add relays/online_relays_gps.csv\n          git commit -m \"Automated update of relay data - $(date -u)\"\n          git push\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}"
  },
  {
    "path": ".github/workflows/swift-tests.yml",
    "content": "name: Build & Test\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n\njobs:\n  test:\n    name: Run Swift Tests\n    runs-on: macos-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v5\n\n      - name: Set up Swift\n        uses: swift-actions/setup-swift@v2\n\n      - name: Build the package\n        run: swift build\n\n      - name: Run Tests\n        run: swift test --parallel\n"
  },
  {
    "path": ".gitignore",
    "content": "# Xcode\n#\n# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore\n\n## implementation plans\nplans/\n\n## AI\nCLAUDE.md\nAGENTS.md\n\n## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)\n*.xcscmblueprint\n*.xccheckout\n\n## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)\nbuild/\nDerivedData/\n.DerivedData/\n*.moved-aside\n*.pbxuser\n!default.pbxuser\n*.mode1v3\n!default.mode1v3\n*.mode2v3\n!default.mode2v3\n*.perspectivev3\n!default.perspectivev3\n\n## Gcc Patch\n/*.gcno\n\n## macOS\n.DS_Store\n\n## SPM\n.swiftpm\n.build/\n\n## CocoaPods\nPods/\n\n## Carthage\nCarthage/Build/\n\n## fastlane\nfastlane/report.xml\nfastlane/Preview.html\nfastlane/screenshots/**/*.png\nfastlane/test_output\n\n## Code Injection\niOSInjectionProject/\n\n## Xcode project\n*.xcodeproj/project.xcworkspace/\n## Xcode User settings\nxcuserdata/\n\n## Python\n__pycache__/\n*.py[cod]\n*$py.class\n\n## Temporary files\n*.tmp\n*.temp\n\n## Cache\n.cache/\n\n# Local build results\n.Result*/\n.Result*.xcresult/\nTestResult.xcresult/\n*.xcresult/\nbuild.log\n*.log\n\n# Local configs\nLocal.xcconfig\n"
  },
  {
    "path": "BRING_THE_NOISE.md",
    "content": "# Bringing the Noise: Secure Communication in BitChat\n\n## Overview\n\nBitChat 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.\n\n## The Noise Protocol Framework\n\n### Why Noise?\n\nThe Noise Protocol Framework offers:\n- **Forward Secrecy**: Past messages remain secure even if keys are compromised\n- **Identity Hiding**: Peer identities are encrypted during handshake\n- **Simplicity**: Clean, auditable protocol with minimal complexity\n- **Performance**: Efficient for resource-constrained mobile devices\n- **Flexibility**: Supports various handshake patterns\n\n### The XX Pattern\n\nBitChat uses the Noise XX pattern:\n```\nXX:\n  -> e\n  <- e, ee, s, es\n  -> s, se\n```\n\nThis three-message pattern provides:\n- Mutual authentication\n- Identity encryption (identities revealed only after initial key exchange)\n- Resistance to key-compromise impersonation\n\n## Implementation Architecture\n\n### Core Components\n\n#### NoiseEncryptionService\nThe main service managing all Noise operations:\n```swift\nfinal class NoiseEncryptionService {\n    private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey\n    private let sessionManager: NoiseSessionManager\n    private let channelEncryption = NoiseChannelEncryption()\n}\n```\n\n#### NoiseSession\nIndividual session state for each peer:\n```swift\nfinal class NoiseSession {\n    private var handshakeState: NoiseHandshakeState?\n    private var sendCipher: NoiseCipherState?\n    private var receiveCipher: NoiseCipherState?\n    private let remoteStaticKey: Curve25519.KeyAgreement.PublicKey?\n}\n```\n\n#### NoiseSessionManager\nThread-safe session management:\n```swift\nfinal class NoiseSessionManager {\n    private var sessions: [String: NoiseSession] = [:]\n    private let sessionsQueue = DispatchQueue(label: \"noise.sessions\", attributes: .concurrent)\n}\n```\n\n### Handshake Flow\n\n1. **Initiator sends ephemeral key**\n   ```swift\n   let ephemeralKey = Curve25519.KeyAgreement.PrivateKey()\n   let message = ephemeralKey.publicKey.rawRepresentation\n   ```\n\n2. **Responder sends ephemeral + encrypted static**\n   ```swift\n   // Generate ephemeral, perform DH, encrypt static key\n   let encryptedStatic = encrypt(staticKey, using: sharedSecret)\n   ```\n\n3. **Initiator sends encrypted static**\n   ```swift\n   // Complete handshake, derive session keys\n   let (sendKey, recvKey) = deriveSessionKeys(transcript)\n   ```\n\n### Session Management\n\nSessions are managed with automatic cleanup and rekey support:\n\n```swift\n// Session lookup by peer ID\nfunc getSession(for peerID: String) -> NoiseSession?\n\n// Automatic session removal on disconnect\nfunc removeSession(for peerID: String)\n\n// Rekey detection\nfunc getSessionsNeedingRekey() -> [(String, Bool)]\n```\n\n## Integration with BitChat\n\n### Peer ID Rotation\n\nNoise sessions persist across peer ID rotations through fingerprint mapping:\n\n```swift\n// Identity announcement after handshake\nstruct NoiseIdentityAnnouncement {\n    let peerID: String\n    let publicKey: Data\n    let nickname: String\n    let previousPeerID: String?\n    let signature: Data\n}\n```\n\n### Message Encryption\n\nAll messages are encrypted using established Noise sessions:\n\n```swift\n// Encrypt message\nlet encrypted = try noiseService.encrypt(messageData, for: peerID)\n\n// Decrypt message  \nlet decrypted = try noiseService.decrypt(encryptedData, from: peerID)\n```\n\n## Security Properties\n\n### Forward Secrecy\n- Ephemeral keys are generated for each handshake\n- Past sessions cannot be decrypted with current keys\n- Automatic rekey after 1 hour or 10,000 messages\n\n### Authentication\n- Static keys provide long-term identity\n- Handshake ensures mutual authentication\n- MAC tags prevent message tampering\n\n### Privacy\n- Peer identities encrypted during handshake\n- Metadata minimization through padding\n- No persistent session identifiers\n\n## Implementation Details\n\n### Cryptographic Primitives\n- **DH**: X25519 (Curve25519)\n- **Cipher**: ChaChaPoly (AEAD)\n- **Hash**: SHA-256\n- **KDF**: HKDF-SHA256\n\n### Error Handling\n```swift\nenum NoiseError: Error {\n    case handshakeFailed\n    case invalidMessage\n    case sessionNotEstablished\n    case decryptionFailed\n}\n```\n\n## Performance Optimizations\n\n### Connection Pooling\n- Reuse established sessions\n- Lazy handshake initiation\n- Session caching with TTL\n\n### Message Batching\n- Combine small messages\n- Reduce encryption overhead\n- Optimize for BLE MTU\n\n### Memory Management\n- Bounded session cache\n- Automatic cleanup of stale sessions\n- Efficient key rotation\n\n## Protocol Version Negotiation\n\nBitChat implements protocol version negotiation to ensure compatibility between different client versions:\n\n### Version Negotiation Flow\n1. **Version Hello**: Upon connection, peers exchange supported protocol versions\n2. **Version Agreement**: Peers agree on the highest common version\n3. **Graceful Fallback**: Legacy peers without version negotiation assume protocol v1\n\n### Message Types\n```swift\ncase versionHello = 0x20    // Announce supported versions\ncase versionAck = 0x21      // Acknowledge and agree on version\n```\n\n### Backward Compatibility\n- Peers that don't send version negotiation messages are assumed to support v1\n- Future protocol versions can be added to `ProtocolVersion.supportedVersions`\n- Incompatible peers receive a rejection message and disconnect gracefully\n\n## Future Enhancements\n\n### Post-Quantum Readiness\n- Hybrid handshake patterns\n- Kyber integration plans\n- Graceful algorithm migration\n\n### Advanced Features\n- Multi-device support\n- Session backup/restore\n- Group messaging primitives\n\n## Conclusion\n\nBitChat'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.\n"
  },
  {
    "path": "Configs/Debug.xcconfig",
    "content": "#include \"Release.xcconfig\"\n\n// Optional include of local configs\n#include? \"Local.xcconfig\"\n"
  },
  {
    "path": "Configs/Local.xcconfig.example",
    "content": "// Your Apple Developer Team ID - https://stackoverflow.com/a/18727947\nDEVELOPMENT_TEAM = ABC123\n\n// Unique bundle id to be able to register and run locally\nPRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)\n"
  },
  {
    "path": "Configs/Release.xcconfig",
    "content": "MARKETING_VERSION = 1.5.1\nCURRENT_PROJECT_VERSION = 1\n\nIPHONEOS_DEPLOYMENT_TARGET = 16.0\nMACOSX_DEPLOYMENT_TARGET = 13.0\nSWIFT_VERSION = 5.0\n\nDEVELOPMENT_TEAM = L3N5LHJD5Y\nCODE_SIGN_STYLE = Automatic\n\nPRODUCT_BUNDLE_IDENTIFIER = chat.bitchat\n"
  },
  {
    "path": "Justfile",
    "content": "# BitChat macOS Build Justfile\n# Handles temporary modifications needed to build and run on macOS\n\n# Default recipe - shows available commands\ndefault:\n    @echo \"BitChat macOS Build Commands:\"\n    @echo \"  just run     - Build and run the macOS app\"\n    @echo \"  just build   - Build the macOS app only\"\n    @echo \"  just clean   - Clean build artifacts and restore original files\"\n    @echo \"  just check   - Check prerequisites\"\n    @echo \"\"\n    @echo \"Original files are preserved - modifications are temporary for builds only\"\n\n# Check prerequisites\ncheck:\n    @echo \"Checking prerequisites...\"\n    @command -v xcodebuild >/dev/null 2>&1 || (echo \"❌ xcodebuild not found. Install Xcode from App Store\" && exit 1)\n    @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)\n    @test -d \"/Applications/Xcode.app\" || (echo \"❌ Xcode.app not found in Applications folder. Install from App Store\" && exit 1)\n    @xcodebuild -version >/dev/null 2>&1 || (echo \"❌ Xcode not properly configured. Try:\\n   sudo xcode-select -s /Applications/Xcode.app/Contents/Developer\" && exit 1)\n    @security find-identity -v -p codesigning | grep -q \"Apple Development\\|Developer ID\" || (echo \"⚠️  No Developer ID found - code signing may fail\" && exit 0)\n    @echo \"✅ All prerequisites met\"\n\n# Backup original files\nbackup:\n    @echo \"Backing up original project configuration...\"\n    @if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi\n    @if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi\n\n# Restore original files\nrestore:\n    @echo \"Restoring original project configuration...\"\n    @if [ -f project.yml.backup ]; then mv project.yml.backup project.yml; fi\n    @# Restore iOS-specific files\n    @if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi\n    @# Use git to restore all modified files except Justfile\n    @git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo \"⚠️  Could not restore some files with git\"\n    @# Remove any backup files\n    @rm -f bitchat.xcodeproj/project.pbxproj.backup bitchat/Info.plist.backup 2>/dev/null || true\n\n# Apply macOS-specific modifications\npatch-for-macos: backup\n    @echo \"Temporarily hiding iOS-specific files for macOS build...\"\n    @# Move iOS-specific files out of the way temporarily\n    @if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi\n\n# Build the macOS app\nbuild: #check generate\n    @echo \"Building BitChat for macOS...\"\n    @xcodebuild -project bitchat.xcodeproj -scheme \"bitchat (macOS)\" -configuration Debug CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS=\"\" build\n\n# Run the macOS app\nrun: build\n    @echo \"Launching BitChat...\"\n    @find ~/Library/Developer/Xcode/DerivedData -name \"bitchat.app\" -path \"*/Debug/*\" -not -path \"*/Index.noindex/*\" | head -1 | xargs -I {} open \"{}\"\n\n# Clean build artifacts and restore original files\nclean: restore\n    @echo \"Cleaning build artifacts...\"\n    @rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true\n    @# Only remove the generated project if we have a backup, otherwise use git\n    @if [ -f bitchat.xcodeproj/project.pbxproj.backup ]; then \\\n        rm -rf bitchat.xcodeproj; \\\n    else \\\n        git checkout -- bitchat.xcodeproj/project.pbxproj 2>/dev/null || echo \"⚠️  Could not restore project.pbxproj\"; \\\n    fi\n    @rm -f project-macos.yml 2>/dev/null || true\n    @echo \"✅ Cleaned and restored original files\"\n\n# Quick run without cleaning (for development)\ndev-run: check\n    @echo \"Quick development build...\"\n    @xcodebuild -project bitchat.xcodeproj -scheme \"bitchat_macOS\" -configuration Debug CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS=\"\" build\n    @find ~/Library/Developer/Xcode/DerivedData -name \"bitchat.app\" -path \"*/Debug/*\" -not -path \"*/Index.noindex/*\" | head -1 | xargs -I {} open \"{}\"\n\n# Show app info\ninfo:\n    @echo \"BitChat - Decentralized Mesh Messaging\"\n    @echo \"======================================\"\n    @echo \"• Native macOS SwiftUI app\"\n    @echo \"• Bluetooth LE mesh networking\"\n    @echo \"• End-to-end encryption\"\n    @echo \"• No internet required\"\n    @echo \"• Works offline with nearby devices\"\n    @echo \"\"\n    @echo \"Requirements:\"\n    @echo \"• macOS 13.0+ (Ventura)\"\n    @echo \"• Bluetooth LE capable Mac\"\n    @echo \"• Physical device (no simulator support)\"\n    @echo \"\"\n    @echo \"Usage:\"\n    @echo \"• Set nickname and start chatting\"\n    @echo \"• Use /join #channel for group chats\"\n    @echo \"• Use /msg @user for private messages\"\n    @echo \"• Triple-tap logo for emergency wipe\"\n\n# Force clean everything (nuclear option)\nnuke:\n    @echo \"🧨 Nuclear clean - removing all build artifacts and backups...\"\n    @rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true\n    @rm -rf bitchat.xcodeproj 2>/dev/null || true\n    @rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true\n    @rm -f bitchat/Info.plist.backup 2>/dev/null || true\n    @# Restore iOS-specific files if they were moved\n    @if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi\n    @git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo \"⚠️  Not a git repo or no changes to restore\"\n    @echo \"✅ Nuclear clean complete\"\n"
  },
  {
    "path": "LICENSE",
    "content": "This is free and unencumbered software released into the public domain.\n\nAnyone is free to copy, modify, publish, use, compile, sell, or\ndistribute this software, either in source code form or as a compiled\nbinary, for any purpose, commercial or non-commercial, and by any\nmeans.\n\nIn jurisdictions that recognize copyright laws, the author or authors\nof this software dedicate any and all copyright interest in the\nsoftware to the public domain. We make this dedication for the benefit\nof the public at large and to the detriment of our heirs and\nsuccessors. We intend this dedication to be an overt act of\nrelinquishment in perpetuity of all present and future rights to this\nsoftware under copyright law.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR\nOTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,\nARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE.\n\nFor more information, please refer to <https://unlicense.org>"
  },
  {
    "path": "PRIVACY_POLICY.md",
    "content": "# bitchat Privacy Policy\n\n*Last updated: January 2025*\n\n## Our Commitment\n\nbitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy.\n\n## Summary\n\n- **No personal data collection** - We don't collect names, emails, or phone numbers\n- **No servers** - Everything happens on your device and through peer-to-peer connections\n- **No tracking** - We have no analytics, telemetry, or user tracking\n- **Open source** - You can verify these claims by reading our code\n\n## What Information bitchat Stores\n\n### On Your Device Only\n\n1. **Identity Key** \n   - A cryptographic key generated on first launch\n   - Stored locally in your device's secure storage\n   - Allows you to maintain \"favorite\" relationships across app restarts\n   - Never leaves your device\n\n2. **Nickname**\n   - The display name you choose (or auto-generated)\n   - Stored only on your device\n   - Shared with peers you communicate with\n\n3. **Message History** (if enabled)\n   - When room owners enable retention, messages are saved locally\n   - Stored encrypted on your device\n   - You can delete this at any time\n\n4. **Favorite Peers**\n   - Public keys of peers you mark as favorites\n   - Stored only on your device\n   - Allows you to recognize these peers in future sessions\n\n### Temporary Session Data\n\nDuring each session, bitchat temporarily maintains:\n- Active peer connections (forgotten when app closes)\n- Routing information for message delivery\n- Cached messages for offline peers (12 hours max)\n\n## What Information is Shared\n\n### With Other bitchat Users\n\nWhen you use bitchat, nearby peers can see:\n- Your chosen nickname\n- Your ephemeral public key (changes each session)\n- Messages you send to public rooms or directly to them\n- Your approximate Bluetooth signal strength (for connection quality)\n\n### With Room Members\n\nWhen you join a password-protected room:\n- Your messages are visible to others with the password\n- Your nickname appears in the member list\n- Room owners can see you've joined\n\n## What We DON'T Do\n\nbitchat **never**:\n- Collects personal information\n- Tracks your location\n- Stores data on servers\n- Shares data with third parties\n- Uses analytics or telemetry\n- Creates user profiles\n- Requires registration\n\n## Encryption\n\nAll private messages use end-to-end encryption:\n- **X25519** for key exchange\n- **AES-256-GCM** for message encryption\n- **Ed25519** for digital signatures\n- **Argon2id** for password-protected rooms\n\n## Your Rights\n\nYou have complete control:\n- **Delete Everything**: Triple-tap the logo to instantly wipe all data\n- **Leave Anytime**: Close the app and your presence disappears\n- **No Account**: Nothing to delete from servers because there are none\n- **Portability**: Your data never leaves your device unless you export it\n\n## Bluetooth & Permissions\n\nbitchat requires Bluetooth permission to function:\n- Used only for peer-to-peer communication\n- No location data is accessed or stored\n- Bluetooth is not used for tracking\n- You can revoke this permission at any time in system settings\n\n## Children's Privacy\n\nbitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.\n\n## Data Retention\n\n- **Messages**: Deleted from memory when app closes (unless room retention is enabled)\n- **Identity Key**: Persists until you delete the app\n- **Favorites**: Persist until you remove them or delete the app\n- **Everything Else**: Exists only during active sessions\n\n## Security Measures\n\n- All communication is encrypted\n- No data transmitted to servers (there are none)\n- Open source code for public audit\n- Regular security updates\n- Cryptographic signatures prevent tampering\n\n## Changes to This Policy\n\nIf we update this policy:\n- The \"Last updated\" date will change\n- The updated policy will be included in the app\n- No retroactive changes can affect data (since we don't collect any)\n\n## Contact\n\nbitchat is an open source project. For privacy questions:\n- View our source code: [https://github.com/permissionlesstech/bitchat/tree/main](https://github.com/permissionlesstech/bitchat/tree/main)\n- Open an issue on GitHub\n- Join the discussion in public rooms\n\n## Philosophy\n\nPrivacy 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.\n\n---\n\n*This policy is released into the public domain under The Unlicense, just like bitchat itself.*\n"
  },
  {
    "path": "Package.resolved",
    "content": "{\n  \"pins\" : [\n    {\n      \"identity\" : \"swift-secp256k1\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/21-DOT-DEV/swift-secp256k1\",\n      \"state\" : {\n        \"revision\" : \"8c62aba8a3011c9bcea232e5ee007fb0b34a15e2\",\n        \"version\" : \"0.21.1\"\n      }\n    }\n  ],\n  \"version\" : 2\n}\n"
  },
  {
    "path": "Package.swift",
    "content": "// swift-tools-version: 5.9\n\nimport PackageDescription\n\nlet package = Package(\n    name: \"bitchat\",\n    defaultLocalization: \"en\",\n    platforms: [\n        .iOS(.v16),\n        .macOS(.v13)\n    ],\n    products: [\n        .executable(\n            name: \"bitchat\",\n            targets: [\"bitchat\"]\n        ),\n    ],\n    dependencies:[\n        .package(path: \"localPackages/Arti\"),\n        .package(path: \"localPackages/BitLogger\"),\n        .package(url: \"https://github.com/21-DOT-DEV/swift-secp256k1\", exact: \"0.21.1\")\n    ],\n    targets: [\n        .executableTarget(\n            name: \"bitchat\",\n            dependencies: [\n                .product(name: \"P256K\", package: \"swift-secp256k1\"),\n                .product(name: \"BitLogger\", package: \"BitLogger\"),\n                .product(name: \"Tor\", package: \"Arti\")\n            ],\n            path: \"bitchat\",\n            exclude: [\n                \"Info.plist\",\n                \"Assets.xcassets\",\n                \"bitchat.entitlements\",\n                \"bitchat-macOS.entitlements\",\n                \"LaunchScreen.storyboard\",\n                \"ViewModels/Extensions/README.md\"\n            ],\n            resources: [\n                .process(\"Localizable.xcstrings\")\n            ]\n        ),\n        .testTarget(\n            name: \"bitchatTests\",\n            dependencies: [\"bitchat\"],\n            path: \"bitchatTests\",\n            exclude: [\n                \"Info.plist\",\n                \"README.md\"\n            ],\n            resources: [\n                .process(\"Localization\"),\n                .process(\"Noise\")\n            ]\n        )\n    ]\n)\n"
  },
  {
    "path": "README.md",
    "content": "<img width=\"256\" height=\"256\" alt=\"icon_128x128@2x\" src=\"https://github.com/user-attachments/assets/90133f83-b4f6-41c6-aab9-25d0859d2a47\" />\n\n## bitchat\n\nA 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.\n\n[bitchat.free](http://bitchat.free)\n\n📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)\n\n## License\n\nThis project is released into the public domain. See the [LICENSE](LICENSE) file for details.\n\n## Features\n\n- **Dual Transport Architecture**: Bluetooth mesh for offline + Nostr protocol for internet-based messaging\n- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays\n- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)\n- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE\n- **Privacy First**: No accounts, no phone numbers, no persistent identifiers\n- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr\n- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface\n- **Universal App**: Native support for iOS and macOS\n- **Emergency Wipe**: Triple-tap to instantly clear all data\n- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking\n\n## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)\n\nBitChat uses a **hybrid messaging architecture** with two complementary transport layers:\n\n### Bluetooth Mesh Network (Offline)\n\n- **Local Communication**: Direct peer-to-peer within Bluetooth range\n- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)\n- **No Internet Required**: Works completely offline in disaster scenarios\n- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy\n- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints\n- **Automatic Discovery**: Peer discovery and connection management\n- **Adaptive Power**: Battery-optimized duty cycling\n\n### Nostr Protocol (Internet)\n\n- **Global Reach**: Connect with users worldwide via internet relays\n- **Location Channels**: Geographic chat rooms using geohash coordinates\n- **290+ Relay Network**: Distributed across the globe for reliability\n- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy\n- **Ephemeral Keys**: Fresh cryptographic identity per geohash area\n\n### Channel Types\n\n#### `mesh #bluetooth`\n\n- **Transport**: Bluetooth Low Energy mesh network\n- **Scope**: Local devices within multi-hop range\n- **Internet**: Not required\n- **Use Case**: Offline communication, protests, disasters, remote areas\n\n#### Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`)\n\n- **Transport**: Nostr protocol over internet\n- **Scope**: Geographic areas defined by geohash precision\n  - `block` (7 chars): City block level\n  - `neighborhood` (6 chars): District/neighborhood\n  - `city` (5 chars): City level\n  - `province` (4 chars): State/province\n  - `region` (2 chars): Country/large region\n- **Internet**: Required (connects to Nostr relays)\n- **Use Case**: Location-based community chat, local events, regional discussions\n\n### Direct Message Routing\n\nPrivate messages use **intelligent transport selection**:\n\n1. **Bluetooth First** (preferred when available)\n\n   - Direct connection with established Noise session\n   - Fastest and most private option\n\n2. **Nostr Fallback** (when Bluetooth unavailable)\n\n   - Uses recipient's Nostr public key\n   - NIP-17 gift-wrapping for privacy\n   - Routes through global relay network\n\n3. **Smart Queuing** (when neither available)\n   - Messages queued until transport becomes available\n   - Automatic delivery when connection established\n\nFor detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).\n\n## Setup\n\n### Option 1: Using Xcode\n\n   ```bash\n   cd bitchat\n   open bitchat.xcodeproj\n   ```\n\n   To run on a device there're a few steps to prepare the code:\n   - Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`\n   - Add your Developer Team ID into the newly created `Configs/Local.xcconfig`\n      - Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)\n   - Entitlements need to be updated manually (TODO: Automate):\n      - Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)\n\n### Option 2: Using `just`\n\n   ```bash\n   brew install just\n   ```\n\nWant to try this on macos: `just run` will set it up and run from source.\nRun `just clean` afterwards to restore things to original state for mobile app building and development.\n\n## Localization\n\n- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.\n- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.\n- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.\n- Run `xcodebuild -project bitchat.xcodeproj -scheme \"bitchat (macOS)\" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.\n"
  },
  {
    "path": "WHITEPAPER.md",
    "content": "# BitChat Protocol Whitepaper\n\n**Version 1.1**\n\n**Date: July 25, 2025**\n\n---\n\n## Abstract\n\nBitChat 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.\n\n---\n\n## 1. Introduction\n\nIn 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).\n\nThe design goals of the BitChat Protocol are:\n\n*   **Confidentiality:** All communication must be unreadable to third parties.\n*   **Authentication:** Users must be able to verify the identity of their correspondents.\n*   **Integrity:** Messages cannot be tampered with in transit.\n*   **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys.\n*   **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message.\n*   **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments.\n\nThis paper specifies the technical details of the protocol designed to meet these goals.\n\n---\n\n## 2. Protocol Stack\n\nThe BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility.\n\n```mermaid\ngraph TD\n    A[Application Layer] --> B[Session Layer];\n    B --> C[Encryption Layer];\n    C --> D[Transport Layer];\n\n    subgraph \"BitChat Application\"\n        A\n    end\n\n    subgraph \"Message Framing & State\"\n        B\n    end\n\n    subgraph \"Noise Protocol Framework\"\n        C\n    end\n\n    subgraph \"BLE, Wi-Fi Direct, etc.\"\n        D\n    end\n\n    style A fill:#cde4ff\n    style B fill:#b5d8ff\n    style C fill:#9ac2ff\n    style D fill:#7eadff\n```\n\n*   **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data.\n*   **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format.\n*   **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.\n*   **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.\n\n---\n\n## 3. Identity and Key Management\n\nA 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.\n\n1.  **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.\n2.  **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.\n\n### 3.1. Fingerprint\n\nA 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).\n\n`Fingerprint = SHA256(StaticPublicKey_Curve25519)`\n\n### 3.2. Identity Management\n\nThe `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.\n\n---\n\n## 4. The Social Trust Layer\n\nBeyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`.\n\n### 4.1. Peer Verification\n\nWhile 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.\n\n### 4.2. Favorites and Blocking\n\nTo improve the user experience and provide control over interactions, the protocol supports:\n*   **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.\n*   **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.\n\n---\n\n## 5. The Noise Protocol Layer\n\nBitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption.\n\n### 5.1. Protocol Name\n\nThe specific Noise protocol implemented is:\n\n**`Noise_XX_25519_ChaChaPoly_SHA256`**\n\n*   **`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.\n*   **`25519`:** The Diffie-Hellman function used is Curve25519.\n*   **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305.\n*   **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256.\n\n### 5.2. The `XX` Handshake\n\nThe `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys.\n\n```mermaid\nsequenceDiagram\n    participant I as Initiator\n    participant R as Responder\n\n    Note over I, R: Pre-computation: h = SHA256(protocol_name)\n\n    I->>R: -> e\n    Note right of I: I generates ephemeral key `e_i`.<br/>h = SHA256(h + e_i.pub)\n\n    R->>I: <- e, ee, s, es\n    Note left of R: R generates ephemeral key `e_r`.<br/>h = SHA256(h + e_r.pub)<br/>MixKey(DH(e_i, e_r))<br/>R sends static key `s_r`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(e_i, s_r))\n\n    I->>R: -> s, se\n    Note right of I: I decrypts and verifies `s_r`.<br/>I sends static key `s_i`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(s_i, e_r))\n\n    Note over I, R: Handshake complete. Transport keys derived.\n```\n\n**Handshake Flow:**\n\n1.  **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder.\n2.  **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`).\n3.  **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`).\n\nUpon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding.\n\n### 5.3. Session Management\n\nThe `NoiseSessionManager` class manages all active Noise sessions. It handles:\n*   Creating sessions for new peers.\n*   Coordinating the handshake process to prevent race conditions.\n*   Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`).\n*   Periodically checking if sessions need to be re-keyed for enhanced security.\n\n---\n\n## 6. The BitChat Session and Application Protocol\n\nOnce a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages.\n\n### 6.1. Binary Packet Format (`BitchatPacket`)\n\nTo 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.\n\n| Field           | Size (bytes) | Description                                                                                             |\n|-----------------|--------------|---------------------------------------------------------------------------------------------------------|\n| **Header**      | **13**       | **Fixed-size header**                                                                                   |\n| Version         | 1            | Protocol version (currently `1`).                                                                       |\n| Type            | 1            | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum.            |\n| TTL             | 1            | Time-To-Live for mesh network routing. Decremented at each hop.                                         |\n| Timestamp       | 8            | `UInt64` millisecond timestamp of packet creation.                                                      |\n| Flags           | 1            | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`).                           |\n| Payload Length  | 2            | `UInt16` length of the payload field.                                                                   |\n| **Variable**    | **...**      | **Variable-size fields**                                                                                |\n| Sender ID       | 8            | 8-byte truncated peer ID of the sender.                                                                 |\n| Recipient ID    | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. |\n| Payload         | Variable     | The actual content of the packet, as defined by the `Type` field.                                       |\n| Signature       | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set.                               |\n\n**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.\n\n```mermaid\n---\nconfig:\n  theme: dark\n---\n---\ntitle: \"BitchatPacket\"\n---\npacket\n+8: \"Version\"\n+8: \"Type\"\n+8: \"TTL\"\n+64: \"Timestamp\"\n+8: \"Flags\"\n+16: \"Payload Length\"\n+64: \"Sender ID\"\n+64: \"Recipient ID (optional)\"\n+48: \"Payload (variable)\"\n+64: \"Signature (optional)\"\n```\n_A representation of the sizes of the fields in `BitchatPacket`_\n\n### 6.2. Application Message Format (`BitchatMessage`)\n\nFor packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.\n\n| Field               | Size (bytes) | Description                                                              |\n|---------------------|--------------|--------------------------------------------------------------------------|\n| Flags               | 1            | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). |\n| Timestamp           | 8            | `UInt64` millisecond timestamp of message creation.                      |\n| ID                  | 1 + len      | `UUID` string for the message.                                           |\n| Sender              | 1 + len      | Nickname of the sender.                                                  |\n| Content             | 2 + len      | The UTF-8 encoded message content.                                       |\n| Original Sender     | 1 + len (opt)| Nickname of the original sender if the message is a relay.               |\n| Recipient Nickname  | 1 + len (opt)| Nickname of the recipient for private messages.                          |\n\n```mermaid\n---\nconfig:\n  theme: dark\n---\n---\ntitle: \"BitchatMessage\"\n---\npacket\n+8: \"Flags\"\n+64: \"Timestamp\"\n+24: \"ID (variable)\"\n+32: \"Sender (variable)\"\n+32: \"Content (variable)\"\n+32: \"Original Sender (variable) (optional)\"\n+32: \"Recipient Nickname (variable) (optional)\"\n```\n_A representation of the sizes of the fields in `BitchatMessage`_\n\n---\n\n## 7. Message Routing and Propagation\n\nBitChat 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.\n\n### 7.1. Direct Connection\n\nThis 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.\n\n### 7.2. Efficient Gossip with Bloom Filters\n\nTo 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.\n\nThe logic is as follows:\n\n1.  A peer receives a packet.\n2.  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.\n3.  If the packet is new, its ID is added to the Bloom filter.\n4.  The peer decrements the packet's Time-To-Live (TTL) field.\n5.  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.\n\nThis mechanism allows packets to \"flood\" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops.\n\n### 7.3. Time-To-Live (TTL)\n\nEvery `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.\n\n### 7.4. Private vs. Broadcast Messages\n\nThe routing logic respects the confidentiality of private messages:\n\n*   **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.\n*   **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.\n\n### 7.5. Message Reliability and Lifecycle\n\nTo function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery.\n\n*   **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.\n*   **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.\n*   **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.\n\n### 7.6. Fragmentation\n\nTransport 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.\n\n*   **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments.\n*   **`fragmentContinue`:** These packets carry the intermediate chunks of the message data.\n*   **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly.\n\nReceiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer.\n\n---\n\n## 8. Security Considerations\n\n*   **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.\n*   **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer.\n*   **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other.\n*   **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.\n*   **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.\n\n---\n\n## 9. Conclusion\n\nThe 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.\n"
  },
  {
    "path": "bitchat/Assets.xcassets/AccentColor.colorset/Contents.json",
    "content": "{\n  \"colors\" : [\n    {\n      \"color\" : {\n        \"color-space\" : \"srgb\",\n        \"components\" : {\n          \"alpha\" : \"1.000\",\n          \"blue\" : \"0.000\",\n          \"green\" : \"1.000\",\n          \"red\" : \"0.000\"\n        }\n      },\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}"
  },
  {
    "path": "bitchat/Assets.xcassets/AppIcon.appiconset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"icon_1024x1024.png\",\n      \"idiom\" : \"universal\",\n      \"platform\" : \"ios\",\n      \"size\" : \"1024x1024\"\n    },\n    {\n      \"filename\" : \"icon_16x16.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"1x\",\n      \"size\" : \"16x16\"\n    },\n    {\n      \"filename\" : \"icon_16x16@2x.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"2x\",\n      \"size\" : \"16x16\"\n    },\n    {\n      \"filename\" : \"icon_32x32.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"1x\",\n      \"size\" : \"32x32\"\n    },\n    {\n      \"filename\" : \"icon_32x32@2x.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"2x\",\n      \"size\" : \"32x32\"\n    },\n    {\n      \"filename\" : \"icon_128x128.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"1x\",\n      \"size\" : \"128x128\"\n    },\n    {\n      \"filename\" : \"icon_128x128@2x.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"2x\",\n      \"size\" : \"128x128\"\n    },\n    {\n      \"filename\" : \"icon_256x256.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"1x\",\n      \"size\" : \"256x256\"\n    },\n    {\n      \"filename\" : \"icon_256x256@2x.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"2x\",\n      \"size\" : \"256x256\"\n    },\n    {\n      \"filename\" : \"icon_512x512.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"1x\",\n      \"size\" : \"512x512\"\n    },\n    {\n      \"filename\" : \"icon_512x512@2x.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"2x\",\n      \"size\" : \"512x512\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "bitchat/Assets.xcassets/AppIconDebug.appiconset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"image-1024.png\",\n      \"idiom\" : \"universal\",\n      \"platform\" : \"ios\",\n      \"size\" : \"1024x1024\"\n    },\n    {\n      \"appearances\" : [\n        {\n          \"appearance\" : \"luminosity\",\n          \"value\" : \"dark\"\n        }\n      ],\n      \"idiom\" : \"universal\",\n      \"platform\" : \"ios\",\n      \"size\" : \"1024x1024\"\n    },\n    {\n      \"appearances\" : [\n        {\n          \"appearance\" : \"luminosity\",\n          \"value\" : \"tinted\"\n        }\n      ],\n      \"idiom\" : \"universal\",\n      \"platform\" : \"ios\",\n      \"size\" : \"1024x1024\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "bitchat/Assets.xcassets/Contents.json",
    "content": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "bitchat/BitchatApp.swift",
    "content": "//\n// BitchatApp.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Tor\nimport SwiftUI\nimport UserNotifications\n\n@main\nstruct BitchatApp: App {\n    static let bundleID = Bundle.main.bundleIdentifier ?? \"chat.bitchat\"\n    static let groupID = \"group.\\(bundleID)\"\n    \n    @StateObject private var chatViewModel: ChatViewModel\n    #if os(iOS)\n    @Environment(\\.scenePhase) var scenePhase\n    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate\n    // Skip the very first .active-triggered Tor restart on cold launch\n    @State private var didHandleInitialActive: Bool = false\n    @State private var didEnterBackground: Bool = false\n    #elseif os(macOS)\n    @NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate\n    #endif\n    \n    private let idBridge = NostrIdentityBridge()\n    \n    init() {\n        let keychain = KeychainManager()\n        let idBridge = self.idBridge\n        _chatViewModel = StateObject(\n            wrappedValue: ChatViewModel(\n                keychain: keychain,\n                idBridge: idBridge,\n                identityManager: SecureIdentityStateManager(keychain)\n            )\n        )\n        \n        UNUserNotificationCenter.current().delegate = NotificationDelegate.shared\n        // Warm up georelay directory and refresh if stale (once/day)\n        GeoRelayDirectory.shared.prefetchIfNeeded()\n    }\n    \n    var body: some Scene {\n        WindowGroup {\n            ContentView()\n                .environmentObject(chatViewModel)\n                .onAppear {\n                    NotificationDelegate.shared.chatViewModel = chatViewModel\n                    // Inject live Noise service into VerificationService to avoid creating new BLE instances\n                    VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())\n                    // Prewarm Nostr identity and QR to make first VERIFY sheet fast\n                    let nickname = chatViewModel.nickname\n                    DispatchQueue.global(qos: .utility).async {\n                        let npub = try? idBridge.getCurrentNostrIdentity()?.npub\n                        _ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)\n                    }\n\n                    appDelegate.chatViewModel = chatViewModel\n\n                    // Initialize network activation policy; will start Tor/Nostr only when allowed\n                    NetworkActivationService.shared.start()\n                    \n                    // Start presence service (will wait for Tor readiness)\n                    GeohashPresenceService.shared.start()\n\n                    // Check for shared content\n                    checkForSharedContent()\n                }\n                .onOpenURL { url in\n                    handleURL(url)\n                }\n                #if os(iOS)\n                .onChange(of: scenePhase) { newPhase in\n                    switch newPhase {\n                    case .background:\n                        // Keep BLE mesh running in background; BLEService adapts scanning automatically\n                        // Always send Tor to dormant on background for a clean restart later.\n                        TorManager.shared.setAppForeground(false)\n                        TorManager.shared.goDormantOnBackground()\n                        // Stop geohash sampling while backgrounded\n                        Task { @MainActor in\n                            chatViewModel.endGeohashSampling()\n                        }\n                        // Proactively disconnect Nostr to avoid spurious socket errors while Tor is down\n                        NostrRelayManager.shared.disconnect()\n                        didEnterBackground = true\n                    case .active:\n                        // Restart services when becoming active\n                        chatViewModel.meshService.startServices()\n                        TorManager.shared.setAppForeground(true)\n                        // On initial cold launch, Tor was just started in onAppear.\n                        // Skip the deterministic restart the first time we become active.\n                        if didHandleInitialActive && didEnterBackground {\n                            if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {\n                                TorManager.shared.ensureRunningOnForeground()\n                            }\n                        } else {\n                            didHandleInitialActive = true\n                        }\n                        didEnterBackground = false\n                        if TorManager.shared.isAutoStartAllowed() {\n                            Task.detached {\n                                let _ = await TorManager.shared.awaitReady(timeout: 60)\n                                await MainActor.run {\n                                    // Rebuild proxied sessions to bind to the live Tor after readiness\n                                    TorURLSession.shared.rebuild()\n                                    // Reconnect Nostr via fresh sessions; will gate until Tor 100%\n                                    NostrRelayManager.shared.resetAllConnections()\n                                }\n                            }\n                        }\n                        checkForSharedContent()\n                    case .inactive:\n                        break\n                    @unknown default:\n                        break\n                    }\n                }\n                .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in\n                    // Check for shared content when app becomes active\n                    checkForSharedContent()\n                }\n                #elseif os(macOS)\n                .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in\n                    // App became active\n                }\n                #endif\n        }\n        #if os(macOS)\n        .windowStyle(.hiddenTitleBar)\n        .windowResizability(.contentSize)\n        #endif\n    }\n    \n    private func handleURL(_ url: URL) {\n        if url.scheme == \"bitchat\" && url.host == \"share\" {\n            // Handle shared content\n            checkForSharedContent()\n        }\n    }\n    \n    private func checkForSharedContent() {\n        // Check app group for shared content from extension\n        guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {\n            return\n        }\n        \n        guard let sharedContent = userDefaults.string(forKey: \"sharedContent\"),\n              let sharedDate = userDefaults.object(forKey: \"sharedContentDate\") as? Date else {\n            return\n        }\n        \n        // Only process if shared within configured window\n        if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {\n            let contentType = userDefaults.string(forKey: \"sharedContentType\") ?? \"text\"\n            \n            // Clear the shared content\n            userDefaults.removeObject(forKey: \"sharedContent\")\n            userDefaults.removeObject(forKey: \"sharedContentType\")\n            userDefaults.removeObject(forKey: \"sharedContentDate\")\n            // No need to force synchronize here\n            \n            // Send the shared content immediately on the main queue\n            DispatchQueue.main.async {\n                if contentType == \"url\" {\n                    // Try to parse as JSON first\n                    if let data = sharedContent.data(using: .utf8),\n                       let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],\n                       let url = urlData[\"url\"] {\n                        // Send plain URL\n                        self.chatViewModel.sendMessage(url)\n                    } else {\n                        // Fallback to simple URL\n                        self.chatViewModel.sendMessage(sharedContent)\n                    }\n                } else {\n                    self.chatViewModel.sendMessage(sharedContent)\n                }\n            }\n        }\n    }\n}\n\n#if os(iOS)\nfinal class AppDelegate: NSObject, UIApplicationDelegate {\n    weak var chatViewModel: ChatViewModel?\n    \n    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {\n        return true\n    }\n    \n    func applicationWillTerminate(_ application: UIApplication) {\n        chatViewModel?.applicationWillTerminate()\n    }\n}\n#endif\n\n#if os(macOS)\nimport AppKit\n\nfinal class MacAppDelegate: NSObject, NSApplicationDelegate {\n    weak var chatViewModel: ChatViewModel?\n    \n    func applicationWillTerminate(_ notification: Notification) {\n        chatViewModel?.applicationWillTerminate()\n    }\n    \n    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {\n        return true\n    }\n}\n#endif\n\nfinal class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {\n    static let shared = NotificationDelegate()\n    weak var chatViewModel: ChatViewModel?\n    \n    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {\n        let identifier = response.notification.request.identifier\n        let userInfo = response.notification.request.content.userInfo\n        \n        // Check if this is a private message notification\n        if identifier.hasPrefix(\"private-\") {\n            // Get peer ID from userInfo\n            if let peerID = userInfo[\"peerID\"] as? String {\n                DispatchQueue.main.async {\n                    self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))\n                }\n            }\n        }\n        // Handle deeplink (e.g., geohash activity)\n        if let deep = userInfo[\"deeplink\"] as? String, let url = URL(string: deep) {\n            #if os(iOS)\n            DispatchQueue.main.async { UIApplication.shared.open(url) }\n            #else\n            DispatchQueue.main.async { NSWorkspace.shared.open(url) }\n            #endif\n        }\n        \n        completionHandler()\n    }\n    \n    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {\n        let identifier = notification.request.identifier\n        let userInfo = notification.request.content.userInfo\n        \n        // Check if this is a private message notification\n        if identifier.hasPrefix(\"private-\") {\n            // Get peer ID from userInfo\n            if let peerID = userInfo[\"peerID\"] as? String {\n                // Don't show notification if the private chat is already open\n                // Access main-actor-isolated property via Task\n                Task { @MainActor in\n                    if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {\n                        completionHandler([])\n                    } else {\n                        completionHandler([.banner, .sound])\n                    }\n                }\n                return\n            }\n        }\n        // Suppress geohash activity notification if we're already in that geohash channel\n        if identifier.hasPrefix(\"geo-activity-\"),\n           let deep = userInfo[\"deeplink\"] as? String,\n           let gh = deep.components(separatedBy: \"/\").last {\n            if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {\n                completionHandler([])\n                return\n            }\n        }\n        \n        // Show notification in all other cases\n        completionHandler([.banner, .sound])\n    }\n}\n\n"
  },
  {
    "path": "bitchat/Features/media/ImageUtils.swift",
    "content": "import Foundation\nimport ImageIO\nimport UniformTypeIdentifiers\n#if os(iOS)\nimport UIKit\n#else\nimport AppKit\n#endif\n\nenum ImageUtilsError: Error {\n    case invalidImage\n    case encodingFailed\n}\n\nenum ImageUtils {\n    private static let compressionQuality: CGFloat = 0.82\n    private static let targetImageBytes: Int = 45_000\n\n    static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {\n        // Security H1: Check file size BEFORE reading into memory\n        let attrs = try FileManager.default.attributesOfItem(atPath: url.path)\n        guard let fileSize = attrs[.size] as? Int else {\n            throw ImageUtilsError.invalidImage\n        }\n        // Allow up to 10MB source images (will be scaled down)\n        guard fileSize <= 10 * 1024 * 1024 else {\n            throw ImageUtilsError.invalidImage\n        }\n\n        let data = try Data(contentsOf: url)\n        #if os(iOS)\n        guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }\n        return try processImage(image, maxDimension: maxDimension)\n        #else\n        guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }\n        return try processImage(image, maxDimension: maxDimension)\n        #endif\n    }\n\n    #if os(iOS)\n    static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {\n        return try autoreleasepool {\n            // Scale the image first\n            let scaled = scaledImage(image, maxDimension: maxDimension)\n\n            // Get CGImage from UIImage - this is the key to stripping metadata\n            guard let cgImage = scaled.cgImage else {\n                throw ImageUtilsError.encodingFailed\n            }\n\n            // Use CGImageDestination to encode without metadata (same as macOS)\n            var quality = compressionQuality\n            guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {\n                throw ImageUtilsError.encodingFailed\n            }\n\n            // Compress to target size\n            while jpegData.count > targetImageBytes && quality > 0.3 {\n                quality -= 0.1\n                autoreleasepool {\n                    if let next = encodeJPEG(from: cgImage, quality: quality) {\n                        jpegData = next\n                    }\n                }\n            }\n\n            let outputURL = try makeOutputURL()\n            try jpegData.write(to: outputURL, options: .atomic)\n            return outputURL\n        }\n    }\n\n    private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {\n        let size = image.size\n        let maxSide = max(size.width, size.height)\n        guard maxSide > maxDimension else { return image }\n        let scale = maxDimension / maxSide\n        let newSize = CGSize(width: size.width * scale, height: size.height * scale)\n\n        // Draw into a new context to get a clean CGImage without metadata\n        UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)\n        image.draw(in: CGRect(origin: .zero, size: newSize))\n        let rendered = UIGraphicsGetImageFromCurrentImageContext()\n        UIGraphicsEndImageContext()\n        return rendered ?? image\n    }\n\n    // Shared EXIF-stripping JPEG encoder for both iOS and macOS\n    private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {\n        guard let data = CFDataCreateMutable(nil, 0) else {\n            return nil\n        }\n        guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {\n            return nil\n        }\n        // Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)\n        // By only specifying compression quality and no metadata keys,\n        // we ensure a clean JPEG with no privacy-leaking information\n        let options: [CFString: Any] = [\n            kCGImageDestinationLossyCompressionQuality: quality\n        ]\n        CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)\n        guard CGImageDestinationFinalize(destination) else {\n            return nil\n        }\n        return data as Data\n    }\n    #else\n    static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {\n        return try autoreleasepool {\n            let scaled = scaledImage(image, maxDimension: maxDimension)\n            guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {\n                throw ImageUtilsError.encodingFailed\n            }\n            let width = inputCG.width\n            let height = inputCG.height\n            let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()\n            guard let context = CGContext(\n                data: nil,\n                width: width,\n                height: height,\n                bitsPerComponent: 8,\n                bytesPerRow: 0,\n                space: colorSpace,\n                bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue\n            ) else {\n                throw ImageUtilsError.encodingFailed\n            }\n            context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))\n            guard let cgImage = context.makeImage() else {\n                throw ImageUtilsError.encodingFailed\n            }\n            var quality = compressionQuality\n            guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {\n                throw ImageUtilsError.encodingFailed\n            }\n            while jpegData.count > targetImageBytes && quality > 0.3 {\n                quality -= 0.1\n                autoreleasepool {\n                    if let next = encodeJPEG(from: cgImage, quality: quality) {\n                        jpegData = next\n                    }\n                }\n            }\n            let outputURL = try makeOutputURL()\n            try jpegData.write(to: outputURL, options: .atomic)\n            return outputURL\n        }\n    }\n\n    private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {\n        let size = image.size\n        let maxSide = max(size.width, size.height)\n        guard maxSide > maxDimension else { return image }\n        let scale = maxDimension / maxSide\n        let newSize = NSSize(width: size.width * scale, height: size.height * scale)\n        let scaledImage = NSImage(size: newSize)\n        scaledImage.lockFocus()\n        image.draw(in: NSRect(origin: .zero, size: newSize),\n                   from: NSRect(origin: .zero, size: size),\n                   operation: .copy,\n                   fraction: 1.0)\n        scaledImage.unlockFocus()\n        return scaledImage\n    }\n\n    // Shared EXIF-stripping JPEG encoder for both iOS and macOS\n    private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {\n        guard let data = CFDataCreateMutable(nil, 0) else {\n            return nil\n        }\n        guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {\n            return nil\n        }\n        // Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)\n        // By only specifying compression quality and no metadata keys,\n        // we ensure a clean JPEG with no privacy-leaking information\n        let options: [CFString: Any] = [\n            kCGImageDestinationLossyCompressionQuality: quality\n        ]\n        CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)\n        guard CGImageDestinationFinalize(destination) else {\n            return nil\n        }\n        return data as Data\n    }\n    #endif\n\n    private static func makeOutputURL() throws -> URL {\n        let formatter = DateFormatter()\n        formatter.dateFormat = \"yyyyMMdd_HHmmss\"\n        let fileName = \"img_\\(formatter.string(from: Date())).jpg\"\n\n        let directory = try applicationFilesDirectory().appendingPathComponent(\"images/outgoing\", isDirectory: true)\n        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)\n        return directory.appendingPathComponent(fileName)\n    }\n\n    private static func applicationFilesDirectory() throws -> URL {\n        let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)\n        return base.appendingPathComponent(\"files\", isDirectory: true)\n    }\n}\n"
  },
  {
    "path": "bitchat/Features/voice/VoiceNotePlaybackController.swift",
    "content": "import Foundation\nimport AVFoundation\nimport BitLogger\n\n/// Controls playback for a single voice note and coordinates exclusive playback across the app.\nfinal class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {\n    @Published private(set) var isPlaying: Bool = false\n    @Published private(set) var currentTime: TimeInterval = 0\n    @Published private(set) var duration: TimeInterval = 0\n    @Published private(set) var progress: Double = 0\n\n    private var player: AVAudioPlayer?\n    private var timer: Timer?\n    private var url: URL\n\n    init(url: URL) {\n        self.url = url\n        super.init()\n        // Don't load anything eagerly - wait until user interaction or view is fully displayed\n    }\n\n    func loadDuration() {\n        guard duration == 0 else { return }\n\n        DispatchQueue.global(qos: .utility).async { [weak self] in\n            guard let self = self else { return }\n            do {\n                let player = try AVAudioPlayer(contentsOf: self.url)\n                let loadedDuration = player.duration\n                DispatchQueue.main.async { [weak self] in\n                    guard let self = self, self.duration == 0 else { return }\n                    self.duration = loadedDuration\n                }\n            } catch {\n                SecureLogger.error(\"Failed to load audio duration: \\(error)\", category: .session)\n            }\n        }\n    }\n\n    deinit {\n        timer?.invalidate()\n    }\n\n    func replaceURL(_ url: URL) {\n        guard url != self.url else { return }\n        stop()\n        self.url = url\n        player = nil\n        duration = 0\n        // Duration will be loaded on demand when needed\n    }\n\n    func togglePlayback() {\n        isPlaying ? pause() : play()\n    }\n\n    func play() {\n        guard ensurePlayerReady() else { return }\n        VoiceNotePlaybackCoordinator.shared.activate(self)\n        player?.play()\n        startTimer()\n        updateProgress()\n        isPlaying = true\n    }\n\n    func pause() {\n        player?.pause()\n        stopTimer()\n        updateProgress()\n        isPlaying = false\n    }\n\n    func stop() {\n        player?.stop()\n        player?.currentTime = 0\n        stopTimer()\n        updateProgress()\n        isPlaying = false\n        VoiceNotePlaybackCoordinator.shared.deactivate(self)\n    }\n\n    func seek(to fraction: Double) {\n        guard ensurePlayerReady() else { return }\n        let clamped = max(0, min(1, fraction))\n        if let player = player {\n            player.currentTime = clamped * player.duration\n            if isPlaying {\n                player.play()\n            }\n            updateProgress()\n        }\n    }\n\n    // MARK: - AVAudioPlayerDelegate\n\n    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {\n        // Delegate callback may be on background thread - ensure main thread for UI updates\n        DispatchQueue.main.async { [weak self] in\n            guard let self = self else { return }\n            self.stopTimer()\n            self.updateProgress()\n            self.isPlaying = false\n            VoiceNotePlaybackCoordinator.shared.deactivate(self)\n        }\n    }\n\n    // MARK: - Private Helpers\n\n    private func preparePlayer(for url: URL) {\n        // Prepare player synchronously (only called when playback is requested)\n        do {\n            let player = try AVAudioPlayer(contentsOf: url)\n            player.delegate = self\n            player.prepareToPlay()\n            self.player = player\n            duration = player.duration\n            currentTime = player.currentTime\n            progress = duration > 0 ? currentTime / duration : 0\n        } catch {\n            SecureLogger.error(\"Voice note playback failed for \\(url.lastPathComponent): \\(error)\", category: .session)\n            player = nil\n            duration = 0\n            currentTime = 0\n            progress = 0\n        }\n    }\n\n    private func ensurePlayerReady() -> Bool {\n        if player == nil {\n            preparePlayer(for: url)\n        }\n        #if os(iOS)\n        let session = AVAudioSession.sharedInstance()\n        do {\n            try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])\n            try session.setActive(true, options: [])\n        } catch {\n            SecureLogger.error(\"Failed to activate audio session: \\(error)\", category: .session)\n        }\n        #endif\n        return player != nil\n    }\n\n    private func startTimer() {\n        if timer != nil { return }\n        timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in\n            self?.updateProgress()\n        }\n        if let timer = timer {\n            RunLoop.main.add(timer, forMode: .common)\n        }\n    }\n\n    private func stopTimer() {\n        timer?.invalidate()\n        timer = nil\n    }\n\n    private func updateProgress() {\n        guard let player = player else {\n            currentTime = 0\n            duration = 0\n            progress = 0\n            return\n        }\n        currentTime = player.currentTime\n        duration = player.duration\n        progress = duration > 0 ? currentTime / duration : 0\n    }\n}\n\n/// Ensures only one voice note plays at a time.\nfinal class VoiceNotePlaybackCoordinator {\n    static let shared = VoiceNotePlaybackCoordinator()\n\n    private weak var activeController: VoiceNotePlaybackController?\n\n    private init() {}\n\n    func activate(_ controller: VoiceNotePlaybackController) {\n        if activeController === controller {\n            return\n        }\n        activeController?.pause()\n        activeController = controller\n    }\n\n    func deactivate(_ controller: VoiceNotePlaybackController) {\n        if activeController === controller {\n            activeController = nil\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Features/voice/VoiceRecorder.swift",
    "content": "import Foundation\nimport AVFoundation\n\n/// Manages audio capture for mesh voice notes with predictable encoding settings.\n/// Recording runs on an internal serial queue to avoid AVAudioSession contention.\nfinal class VoiceRecorder: NSObject, AVAudioRecorderDelegate {\n    enum RecorderError: Error {\n        case microphoneAccessDenied\n        case recorderInitializationFailed\n        case recordingInProgress\n    }\n\n    static let shared = VoiceRecorder()\n\n    private let queue = DispatchQueue(label: \"com.bitchat.voice-recorder\")\n    private let paddingInterval: TimeInterval = 0.5\n    private let maxRecordingDuration: TimeInterval = 120\n\n    private var recorder: AVAudioRecorder?\n    private var currentURL: URL?\n    private var stopWorkItem: DispatchWorkItem?\n\n    private override init() {\n        super.init()\n    }\n\n    // MARK: - Permissions\n\n    @discardableResult\n    func requestPermission() async -> Bool {\n        #if os(iOS)\n        return await withCheckedContinuation { continuation in\n            AVAudioSession.sharedInstance().requestRecordPermission { granted in\n                continuation.resume(returning: granted)\n            }\n        }\n        #elseif os(macOS)\n        return await withCheckedContinuation { continuation in\n            AVCaptureDevice.requestAccess(for: .audio) { granted in\n                continuation.resume(returning: granted)\n            }\n        }\n        #else\n        return true\n        #endif\n    }\n\n    // MARK: - Recording Lifecycle\n\n    func startRecording() throws -> URL {\n        try queue.sync {\n            if recorder?.isRecording == true {\n                throw RecorderError.recordingInProgress\n            }\n\n            #if os(iOS)\n            let session = AVAudioSession.sharedInstance()\n            guard session.recordPermission == .granted else {\n                throw RecorderError.microphoneAccessDenied\n            }\n            #if targetEnvironment(simulator)\n            // allowBluetoothHFP is not available on iOS Simulator\n            try session.setCategory(\n                .playAndRecord,\n                mode: .default,\n                options: [.defaultToSpeaker, .allowBluetoothA2DP]\n            )\n            #else\n            try session.setCategory(\n                .playAndRecord,\n                mode: .default,\n                options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]\n            )\n            #endif\n            try session.setActive(true, options: .notifyOthersOnDeactivation)\n            #endif\n            #if os(macOS)\n            guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {\n                throw RecorderError.microphoneAccessDenied\n            }\n            #endif\n\n            let outputURL = try makeOutputURL()\n            let settings: [String: Any] = [\n                AVFormatIDKey: kAudioFormatMPEG4AAC,\n                AVSampleRateKey: 16_000,\n                AVNumberOfChannelsKey: 1,\n                AVEncoderBitRateKey: 16_000\n            ]\n\n            let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)\n            audioRecorder.delegate = self\n            audioRecorder.isMeteringEnabled = true\n            audioRecorder.prepareToRecord()\n            audioRecorder.record(forDuration: maxRecordingDuration)\n\n            recorder = audioRecorder\n            currentURL = outputURL\n            stopWorkItem?.cancel()\n            stopWorkItem = nil\n            return outputURL\n        }\n    }\n\n    func stopRecording(completion: @escaping (URL?) -> Void) {\n        queue.async { [weak self] in\n            guard let self = self, let recorder = self.recorder, recorder.isRecording else {\n                completion(self?.currentURL)\n                return\n            }\n\n            let item = DispatchWorkItem { [weak self] in\n                guard let self = self else { return }\n                recorder.stop()\n                self.cleanupSession()\n                let url = self.currentURL\n                self.recorder = nil\n                self.currentURL = url\n                completion(url)\n            }\n            self.stopWorkItem = item\n            self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)\n        }\n    }\n\n    func cancelRecording() {\n        queue.async { [weak self] in\n            guard let self = self else { return }\n            self.stopWorkItem?.cancel()\n            self.stopWorkItem = nil\n            if let recorder = self.recorder, recorder.isRecording {\n                recorder.stop()\n            }\n            self.cleanupSession()\n            if let url = self.currentURL {\n                try? FileManager.default.removeItem(at: url)\n            }\n            self.recorder = nil\n            self.currentURL = nil\n        }\n    }\n\n    // MARK: - Metering\n\n    func currentAveragePower() -> Float {\n        queue.sync {\n            recorder?.updateMeters()\n            return recorder?.averagePower(forChannel: 0) ?? -160\n        }\n    }\n\n    // MARK: - Helpers\n\n    private func makeOutputURL() throws -> URL {\n        let formatter = DateFormatter()\n        formatter.dateFormat = \"yyyyMMdd_HHmmss\"\n        let fileName = \"voice_\\(formatter.string(from: Date())).m4a\"\n\n        let baseDirectory = try applicationFilesDirectory().appendingPathComponent(\"voicenotes/outgoing\", isDirectory: true)\n        try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)\n        return baseDirectory.appendingPathComponent(fileName)\n    }\n\n    private func applicationFilesDirectory() throws -> URL {\n        #if os(iOS)\n        return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)\n            .appendingPathComponent(\"files\", isDirectory: true)\n        #else\n        let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)\n        return base.appendingPathComponent(\"files\", isDirectory: true)\n        #endif\n    }\n\n    private func cleanupSession() {\n        #if os(iOS)\n        try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)\n        #endif\n    }\n}\n"
  },
  {
    "path": "bitchat/Features/voice/Waveform.swift",
    "content": "import AVFoundation\nimport Foundation\nimport BitLogger\n\n/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.\nfinal class WaveformCache {\n    static let shared = WaveformCache()\n\n    private let queue = DispatchQueue(label: \"com.bitchat.waveform-cache\", attributes: .concurrent)\n    private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]\n    private let maxCacheSize = 20  // Limit cache to prevent unbounded memory growth\n\n    private init() {}\n\n    func cachedWaveform(for url: URL) -> [Float]? {\n        queue.sync {\n            guard let entry = cache[url] else { return nil }\n            return entry.waveform\n        }\n    }\n\n    func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {\n        queue.async { [weak self] in\n            guard let self = self else { return }\n\n            // Check cache (read-only, no update needed on cache hit for performance)\n            if let entry = self.cache[url] {\n                DispatchQueue.main.async { completion(entry.waveform) }\n                return\n            }\n\n            guard let computed = self.computeWaveform(url: url, bins: bins) else {\n                DispatchQueue.main.async { completion([]) }\n                return\n            }\n\n            self.queue.async(flags: .barrier) { [weak self] in\n                guard let self = self else { return }\n\n                // Evict oldest entry if cache is full\n                if self.cache.count >= self.maxCacheSize {\n                    if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {\n                        self.cache.removeValue(forKey: oldest.key)\n                    }\n                }\n\n                self.cache[url] = (computed, Date())\n            }\n            DispatchQueue.main.async { completion(computed) }\n        }\n    }\n\n    func purge(url: URL) {\n        queue.async(flags: .barrier) { [weak self] in\n            self?.cache.removeValue(forKey: url)\n        }\n    }\n\n    func purgeAll() {\n        queue.async(flags: .barrier) { [weak self] in\n            self?.cache.removeAll()\n        }\n    }\n\n    private func computeWaveform(url: URL, bins: Int) -> [Float]? {\n        guard bins > 0 else { return nil }\n        // Use autoreleasepool to manage memory from audio buffer allocations\n        return autoreleasepool {\n            do {\n                let audioFile = try AVAudioFile(forReading: url)\n                let length = Int(audioFile.length)\n                guard length > 0 else { return nil }\n\n                guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {\n                    return nil\n                }\n                try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))\n                guard let channelData = buffer.floatChannelData else { return nil }\n\n                let channelCount = Int(audioFile.processingFormat.channelCount)\n                let frameLength = Int(buffer.frameLength)\n                let samplesPerBin = max(1, frameLength / bins)\n\n                var magnitudes: [Float] = Array(repeating: 0, count: bins)\n                for bin in 0..<bins {\n                    let start = bin * samplesPerBin\n                    let end = min(frameLength, start + samplesPerBin)\n                    if start >= end { break }\n\n                    var sum: Float = 0\n                    var sampleCount = 0\n                    for frame in start..<end {\n                        var sampleValue: Float = 0\n                        for channel in 0..<channelCount {\n                            sampleValue += fabsf(channelData[channel][frame])\n                        }\n                        sum += sampleValue / Float(channelCount)\n                        sampleCount += 1\n                    }\n                    magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0\n                }\n\n                if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {\n                    magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }\n                }\n                return magnitudes\n            } catch {\n                SecureLogger.error(\"Waveform extraction failed for \\(url.lastPathComponent): \\(error)\", category: .session)\n                return nil\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Identity/IdentityModels.swift",
    "content": "//\n// IdentityModels.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\n///\n/// # IdentityModels\n///\n/// Defines BitChat's innovative three-layer identity model that balances\n/// privacy, security, and usability in a decentralized mesh network.\n///\n/// ## Overview\n/// BitChat's identity system separates concerns across three distinct layers:\n/// 1. **Ephemeral Identity**: Short-lived, rotatable peer IDs for privacy\n/// 2. **Cryptographic Identity**: Long-term Noise static keys for security\n/// 3. **Social Identity**: User-assigned names and trust relationships\n///\n/// This separation allows users to maintain stable cryptographic identities\n/// while frequently rotating their network identifiers for privacy.\n///\n/// ## Three-Layer Architecture\n///\n/// ### Layer 1: Ephemeral Identity\n/// - Random 8-byte peer IDs that rotate periodically\n/// - Provides network-level privacy and prevents tracking\n/// - Changes don't affect cryptographic relationships\n/// - Includes handshake state tracking\n///\n/// ### Layer 2: Cryptographic Identity\n/// - Based on Noise Protocol static key pairs\n/// - Fingerprint derived from SHA256 of public key\n/// - Enables end-to-end encryption and authentication\n/// - Persists across peer ID rotations\n///\n/// ### Layer 3: Social Identity\n/// - User-assigned names (petnames) for contacts\n/// - Trust levels from unknown to verified\n/// - Favorite/blocked status\n/// - Personal notes and metadata\n///\n/// ## Privacy Design\n/// The model is designed with privacy-first principles:\n/// - No mandatory persistent storage\n/// - Optional identity caching with user consent\n/// - Ephemeral IDs prevent long-term tracking\n/// - Social mappings stored locally only\n///\n/// ## Trust Model\n/// Four levels of trust:\n/// 1. **Unknown**: New or unverified peers\n/// 2. **Casual**: Basic interaction history\n/// 3. **Trusted**: User has explicitly trusted\n/// 4. **Verified**: Cryptographic verification completed\n///\n/// ## Identity Resolution\n/// When a peer rotates their ephemeral ID:\n/// 1. Cryptographic handshake reveals their fingerprint\n/// 2. System looks up social identity by fingerprint\n/// 3. UI seamlessly maintains user relationships\n/// 4. Historical messages remain properly attributed\n///\n/// ## Conflict Resolution\n/// Handles edge cases like:\n/// - Multiple peers claiming same nickname\n/// - Nickname changes and conflicts\n/// - Identity rotation during active chats\n/// - Network partitions and rejoins\n///\n/// ## Usage Example\n/// ```swift\n/// // When peer connects with new ID\n/// let ephemeral = EphemeralIdentity(peerID: \"abc123\", ...)\n/// // After handshake\n/// let crypto = CryptographicIdentity(fingerprint: \"sha256...\", ...)\n/// // User assigns name\n/// let social = SocialIdentity(localPetname: \"Alice\", ...)\n/// ```\n///\n\nimport Foundation\n\n// MARK: - Three-Layer Identity Model\n\n/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.\n/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.\nstruct EphemeralIdentity {\n    let peerID: PeerID          // 8 random bytes\n    let sessionStart: Date\n    var handshakeState: HandshakeState\n}\n\nenum HandshakeState {\n    case none\n    case initiated\n    case inProgress\n    case completed(fingerprint: String)\n    case failed(reason: String)\n}\n\n/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.\n/// This identity persists across ephemeral ID rotations and enables secure communication.\n/// The fingerprint serves as the permanent identifier for a peer's cryptographic identity.\nstruct CryptographicIdentity: Codable {\n    let fingerprint: String     // SHA256 of public key\n    let publicKey: Data         // Noise static public key\n    // Optional Ed25519 signing public key (used to authenticate public messages)\n    var signingPublicKey: Data? = nil\n    let firstSeen: Date\n    let lastHandshake: Date?\n}\n\n/// Represents the social layer of identity - user-assigned names and trust relationships.\n/// This layer provides human-friendly identification and relationship management.\n/// All data in this layer is local-only and never transmitted over the network.\nstruct SocialIdentity: Codable {\n    let fingerprint: String\n    var localPetname: String?   // User's name for this peer\n    var claimedNickname: String // What peer calls themselves\n    var trustLevel: TrustLevel\n    var isFavorite: Bool\n    var isBlocked: Bool\n    var notes: String?\n}\n\nenum TrustLevel: String, Codable {\n    case unknown = \"unknown\"\n    case casual = \"casual\"\n    case trusted = \"trusted\"\n    case verified = \"verified\"\n}\n\n// MARK: - Identity Cache\n\n/// Persistent storage for identity mappings and relationships.\n/// Provides efficient lookup between fingerprints, nicknames, and social identities.\n/// Storage is optional and controlled by user privacy settings.\nstruct IdentityCache: Codable {\n    // Fingerprint -> Social mapping\n    var socialIdentities: [String: SocialIdentity] = [:]\n    \n    // Nickname -> [Fingerprints] reverse index\n    // Multiple fingerprints can claim same nickname\n    var nicknameIndex: [String: Set<String>] = [:]\n    \n    // Verified fingerprints (cryptographic proof)\n    var verifiedFingerprints: Set<String> = []\n    \n    // Last interaction timestamps (privacy: optional)\n    var lastInteractions: [String: Date] = [:] \n    \n    // Blocked Nostr pubkeys (lowercased hex) for geohash chats\n    var blockedNostrPubkeys: Set<String> = []\n    \n    // Schema version for future migrations\n    var version: Int = 1\n}\n\n//\n\n// MARK: - Migration Support\n//\n"
  },
  {
    "path": "bitchat/Identity/SecureIdentityStateManager.swift",
    "content": "//\n// SecureIdentityStateManager.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\n///\n/// # SecureIdentityStateManager\n///\n/// Manages the persistent storage and retrieval of identity mappings with\n/// encryption at rest. This singleton service maintains the relationship between\n/// ephemeral peer IDs, cryptographic fingerprints, and social identities.\n///\n/// ## Overview\n/// The SecureIdentityStateManager provides a secure, privacy-preserving way to\n/// maintain identity relationships across app launches. It implements:\n/// - Encrypted storage of identity mappings\n/// - In-memory caching for performance\n/// - Thread-safe access patterns\n/// - Automatic debounced persistence\n///\n/// ## Architecture\n/// The manager operates at three levels:\n/// 1. **In-Memory State**: Fast access to active identities\n/// 2. **Encrypted Cache**: Persistent storage in Keychain\n/// 3. **Privacy Controls**: User-configurable persistence settings\n///\n/// ## Security Features\n///\n/// ### Encryption at Rest\n/// - Identity cache encrypted with AES-GCM\n/// - Unique 256-bit encryption key per device\n/// - Key stored separately in Keychain\n/// - No plaintext identity data on disk\n///\n/// ### Privacy by Design\n/// - Persistence is optional (user-controlled)\n/// - Minimal data retention\n/// - No cloud sync or backup\n/// - Automatic cleanup of stale entries\n///\n/// ### Thread Safety\n/// - Concurrent read access via GCD barriers\n/// - Write operations serialized\n/// - Atomic state updates\n/// - No data races or corruption\n///\n/// ## Data Model\n/// Manages three types of identity data:\n/// 1. **Ephemeral Sessions**: Current peer connections\n/// 2. **Cryptographic Identities**: Public keys and fingerprints\n/// 3. **Social Identities**: User-assigned names and trust\n///\n/// ## Persistence Strategy\n/// - Changes batched and debounced (2-second window)\n/// - Automatic save on app termination\n/// - Crash-resistant with atomic writes\n/// - Migration support for schema changes\n///\n/// ## Usage Patterns\n/// ```swift\n/// // Register a new peer identity\n/// manager.registerPeerIdentity(peerID, publicKey, fingerprint)\n/// \n/// // Update social identity\n/// manager.updateSocialIdentity(fingerprint, nickname, trustLevel)\n/// \n/// // Query identity\n/// let identity = manager.resolvePeerIdentity(peerID)\n/// ```\n///\n/// ## Performance Optimizations\n/// - In-memory cache eliminates Keychain roundtrips\n/// - Debounced saves reduce I/O operations\n/// - Efficient data structures for lookups\n/// - Background queue for expensive operations\n///\n/// ## Privacy Considerations\n/// - Users can disable all persistence\n/// - Identity cache can be wiped instantly\n/// - No analytics or telemetry\n/// - Ephemeral mode for high-risk users\n///\n/// ## Future Enhancements\n/// - Selective identity export\n/// - Cross-device identity sync (optional)\n/// - Identity attestation support\n/// - Advanced conflict resolution\n///\n\nimport BitLogger\nimport Foundation\nimport CryptoKit\n\nprotocol SecureIdentityStateManagerProtocol {\n    // MARK: Secure Loading/Saving\n    func forceSave()\n    \n    // MARK: Social Identity Management\n    func getSocialIdentity(for fingerprint: String) -> SocialIdentity?\n    \n    // MARK: Cryptographic Identities\n    func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)\n    func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]\n    func updateSocialIdentity(_ identity: SocialIdentity)\n    \n    // MARK: Favorites Management\n    func getFavorites() -> Set<String>\n    func setFavorite(_ fingerprint: String, isFavorite: Bool)\n    func isFavorite(fingerprint: String) -> Bool\n    \n    // MARK: Blocked Users Management\n    func isBlocked(fingerprint: String) -> Bool\n    func setBlocked(_ fingerprint: String, isBlocked: Bool)\n    \n    // MARK: Geohash (Nostr) Blocking\n    func isNostrBlocked(pubkeyHexLowercased: String) -> Bool\n    func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool)\n    func getBlockedNostrPubkeys() -> Set<String>\n    \n    // MARK: Ephemeral Session Management\n    func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)\n    func updateHandshakeState(peerID: PeerID, state: HandshakeState)\n    \n    // MARK: Cleanup\n    func clearAllIdentityData()\n    func removeEphemeralSession(peerID: PeerID)\n    \n    // MARK: Verification\n    func setVerified(fingerprint: String, verified: Bool)\n    func isVerified(fingerprint: String) -> Bool\n    func getVerifiedFingerprints() -> Set<String>\n}\n\n/// Singleton manager for secure identity state persistence and retrieval.\n/// Provides thread-safe access to identity mappings with encryption at rest.\n/// All identity data is stored encrypted in the device Keychain for security.\nfinal class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {\n    private let keychain: KeychainManagerProtocol\n    private let cacheKey = \"bitchat.identityCache.v2\"\n    private let encryptionKeyName = \"identityCacheEncryptionKey\"\n    \n    // In-memory state\n    private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]\n    private var cryptographicIdentities: [String: CryptographicIdentity] = [:]\n    private var cache: IdentityCache = IdentityCache()\n    \n    // Thread safety\n    private let queue = DispatchQueue(label: \"bitchat.identity.state\", attributes: .concurrent)\n    \n    // Debouncing for keychain saves\n    private var saveTimer: Timer?\n    private let saveDebounceInterval: TimeInterval = 2.0  // Save at most once every 2 seconds\n    private var pendingSave = false\n    \n    // Encryption key\n    private let encryptionKey: SymmetricKey\n    \n    init(_ keychain: KeychainManagerProtocol) {\n        self.keychain = keychain\n        \n        // Generate or retrieve encryption key from keychain\n        let loadedKey: SymmetricKey\n        \n        // Try to load from keychain\n        if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {\n            loadedKey = SymmetricKey(data: keyData)\n            SecureLogger.logKeyOperation(.load, keyType: \"identity cache encryption key\", success: true)\n        }\n        // Generate new key if needed\n        else {\n            loadedKey = SymmetricKey(size: .bits256)\n            let keyData = loadedKey.withUnsafeBytes { Data($0) }\n            // Save to keychain\n            let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)\n            SecureLogger.logKeyOperation(.generate, keyType: \"identity cache encryption key\", success: saved)\n        }\n        \n        self.encryptionKey = loadedKey\n        \n        // Load identity cache on init\n        loadIdentityCache()\n    }\n    \n    deinit {\n        forceSave()\n    }\n    \n    // MARK: - Secure Loading/Saving\n    \n    private func loadIdentityCache() {\n        guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else {\n            // No existing cache, start fresh\n            return\n        }\n        \n        do {\n            let sealedBox = try AES.GCM.SealedBox(combined: encryptedData)\n            let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)\n            cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)\n        } catch {\n            // Log error but continue with empty cache\n            SecureLogger.error(error, context: \"Failed to load identity cache\", category: .security)\n        }\n    }\n    \n    private func saveIdentityCache() {\n        // Mark that we need to save\n        pendingSave = true\n        \n        // Cancel any existing timer\n        saveTimer?.invalidate()\n        \n        // Schedule a new save after the debounce interval\n        saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in\n            self?.performSave()\n        }\n    }\n    \n    private func performSave() {\n        guard pendingSave else { return }\n        pendingSave = false\n        \n        do {\n            let data = try JSONEncoder().encode(cache)\n            let sealedBox = try AES.GCM.seal(data, using: encryptionKey)\n            let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)\n            if saved {\n                SecureLogger.debug(\"Identity cache saved to keychain\", category: .security)\n            }\n        } catch {\n            SecureLogger.error(error, context: \"Failed to save identity cache\", category: .security)\n        }\n    }\n    \n    // Force immediate save (for app termination)\n    func forceSave() {\n        saveTimer?.invalidate()\n        performSave()\n    }\n    \n    // MARK: - Social Identity Management\n    \n    func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {\n        queue.sync {\n            return cache.socialIdentities[fingerprint]\n        }\n    }\n\n    // MARK: - Cryptographic Identities\n\n    /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.\n    /// - Parameters:\n    ///   - fingerprint: SHA-256 hex of the Noise static public key\n    ///   - noisePublicKey: Noise static public key data\n    ///   - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages\n    ///   - claimedNickname: Optional latest claimed nickname to persist into social identity\n    func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {\n        queue.async(flags: .barrier) {\n            let now = Date()\n            if var existing = self.cryptographicIdentities[fingerprint] {\n                // Update keys if changed\n                if existing.publicKey != noisePublicKey {\n                    existing = CryptographicIdentity(\n                        fingerprint: fingerprint,\n                        publicKey: noisePublicKey,\n                        signingPublicKey: signingPublicKey ?? existing.signingPublicKey,\n                        firstSeen: existing.firstSeen,\n                        lastHandshake: now\n                    )\n                    self.cryptographicIdentities[fingerprint] = existing\n                } else {\n                    // Update signing key and lastHandshake\n                    existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey\n                    let updated = CryptographicIdentity(\n                        fingerprint: existing.fingerprint,\n                        publicKey: existing.publicKey,\n                        signingPublicKey: existing.signingPublicKey,\n                        firstSeen: existing.firstSeen,\n                        lastHandshake: now\n                    )\n                    self.cryptographicIdentities[fingerprint] = updated\n                }\n                // Persist updated state (already assigned in branches above)\n            } else {\n                // New entry\n                let entry = CryptographicIdentity(\n                    fingerprint: fingerprint,\n                    publicKey: noisePublicKey,\n                    signingPublicKey: signingPublicKey,\n                    firstSeen: now,\n                    lastHandshake: now\n                )\n                self.cryptographicIdentities[fingerprint] = entry\n            }\n\n            // Optionally persist claimed nickname into social identity\n            if let claimed = claimedNickname {\n                var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(\n                    fingerprint: fingerprint,\n                    localPetname: nil,\n                    claimedNickname: claimed,\n                    trustLevel: .unknown,\n                    isFavorite: false,\n                    isBlocked: false,\n                    notes: nil\n                )\n                // Update claimed nickname if changed\n                if identity.claimedNickname != claimed {\n                    identity.claimedNickname = claimed\n                    self.cache.socialIdentities[fingerprint] = identity\n                } else if self.cache.socialIdentities[fingerprint] == nil {\n                    self.cache.socialIdentities[fingerprint] = identity\n                }\n            }\n\n            self.saveIdentityCache()\n        }\n    }\n\n    /// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID\n    func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {\n        queue.sync {\n            // Defensive: ensure hex and correct length\n            guard peerID.isShort else { return [] }\n            return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }\n        }\n    }\n    \n    func updateSocialIdentity(_ identity: SocialIdentity) {\n        queue.async(flags: .barrier) {\n            let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname\n            self.cache.socialIdentities[identity.fingerprint] = identity\n            \n            // Update nickname index\n            if let previousClaimedNickname,\n               previousClaimedNickname != identity.claimedNickname {\n                self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)\n                if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {\n                    self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)\n                }\n            }\n            \n            // Add new nickname to index\n            if self.cache.nicknameIndex[identity.claimedNickname] == nil {\n                self.cache.nicknameIndex[identity.claimedNickname] = Set<String>()\n            }\n            self.cache.nicknameIndex[identity.claimedNickname]?.insert(identity.fingerprint)\n            \n            // Save to keychain\n            self.saveIdentityCache()\n        }\n    }\n    \n    // MARK: - Favorites Management\n    \n    func getFavorites() -> Set<String> {\n        queue.sync {\n            let favorites = cache.socialIdentities.values\n                .filter { $0.isFavorite }\n                .map { $0.fingerprint }\n            return Set(favorites)\n        }\n    }\n    \n    func setFavorite(_ fingerprint: String, isFavorite: Bool) {\n        queue.async(flags: .barrier) {\n            if var identity = self.cache.socialIdentities[fingerprint] {\n                identity.isFavorite = isFavorite\n                self.cache.socialIdentities[fingerprint] = identity\n            } else {\n                // Create new social identity for this fingerprint\n                let newIdentity = SocialIdentity(\n                    fingerprint: fingerprint,\n                    localPetname: nil,\n                    claimedNickname: \"Unknown\",\n                    trustLevel: .unknown,\n                    isFavorite: isFavorite,\n                    isBlocked: false,\n                    notes: nil\n                )\n                self.cache.socialIdentities[fingerprint] = newIdentity\n            }\n            self.saveIdentityCache()\n        }\n    }\n    \n    func isFavorite(fingerprint: String) -> Bool {\n        queue.sync {\n            return cache.socialIdentities[fingerprint]?.isFavorite ?? false\n        }\n    }\n    \n    // MARK: - Blocked Users Management\n    \n    func isBlocked(fingerprint: String) -> Bool {\n        queue.sync {\n            return cache.socialIdentities[fingerprint]?.isBlocked ?? false\n        }\n    }\n    \n    func setBlocked(_ fingerprint: String, isBlocked: Bool) {\n        SecureLogger.info(\"User \\(isBlocked ? \"blocked\" : \"unblocked\"): \\(fingerprint)\", category: .security)\n        \n        queue.async(flags: .barrier) {\n            if var identity = self.cache.socialIdentities[fingerprint] {\n                identity.isBlocked = isBlocked\n                if isBlocked {\n                    identity.isFavorite = false  // Can't be both favorite and blocked\n                }\n                self.cache.socialIdentities[fingerprint] = identity\n            } else {\n                // Create new social identity for this fingerprint\n                let newIdentity = SocialIdentity(\n                    fingerprint: fingerprint,\n                    localPetname: nil,\n                    claimedNickname: \"Unknown\",\n                    trustLevel: .unknown,\n                    isFavorite: false,\n                    isBlocked: isBlocked,\n                    notes: nil\n                )\n                self.cache.socialIdentities[fingerprint] = newIdentity\n            }\n            self.saveIdentityCache()\n        }\n    }\n\n    // MARK: - Geohash (Nostr) Blocking\n    \n    func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {\n        queue.sync {\n            return cache.blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased())\n        }\n    }\n    \n    func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {\n        let key = pubkeyHexLowercased.lowercased()\n        queue.async(flags: .barrier) {\n            if isBlocked {\n                self.cache.blockedNostrPubkeys.insert(key)\n            } else {\n                self.cache.blockedNostrPubkeys.remove(key)\n            }\n            self.saveIdentityCache()\n        }\n    }\n    \n    func getBlockedNostrPubkeys() -> Set<String> {\n        queue.sync { cache.blockedNostrPubkeys }\n    }\n    \n    // MARK: - Ephemeral Session Management\n    \n    func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {\n        queue.async(flags: .barrier) {\n            self.ephemeralSessions[peerID] = EphemeralIdentity(\n                peerID: peerID,\n                sessionStart: Date(),\n                handshakeState: handshakeState\n            )\n        }\n    }\n    \n    func updateHandshakeState(peerID: PeerID, state: HandshakeState) {\n        queue.async(flags: .barrier) {\n            self.ephemeralSessions[peerID]?.handshakeState = state\n            \n            // If handshake completed, update last interaction\n            if case .completed(let fingerprint) = state {\n                self.cache.lastInteractions[fingerprint] = Date()\n                self.saveIdentityCache()\n            }\n        }\n    }\n    \n    // MARK: - Cleanup\n    \n    func clearAllIdentityData() {\n        SecureLogger.warning(\"Clearing all identity data\", category: .security)\n        \n        queue.async(flags: .barrier) {\n            self.cache = IdentityCache()\n            self.ephemeralSessions.removeAll()\n            self.cryptographicIdentities.removeAll()\n            \n            // Delete from keychain\n            let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)\n            SecureLogger.logKeyOperation(.delete, keyType: \"identity cache\", success: deleted)\n        }\n    }\n    \n    func removeEphemeralSession(peerID: PeerID) {\n        queue.async(flags: .barrier) {\n            self.ephemeralSessions.removeValue(forKey: peerID)\n        }\n    }\n    \n    // MARK: - Verification\n    \n    func setVerified(fingerprint: String, verified: Bool) {\n        SecureLogger.info(\"Fingerprint \\(verified ? \"verified\" : \"unverified\"): \\(fingerprint)\", category: .security)\n        \n        queue.async(flags: .barrier) {\n            if verified {\n                self.cache.verifiedFingerprints.insert(fingerprint)\n            } else {\n                self.cache.verifiedFingerprints.remove(fingerprint)\n            }\n            \n            // Update trust level if social identity exists\n            if var identity = self.cache.socialIdentities[fingerprint] {\n                identity.trustLevel = verified ? .verified : .casual\n                self.cache.socialIdentities[fingerprint] = identity\n            }\n            \n            self.saveIdentityCache()\n        }\n    }\n    \n    func isVerified(fingerprint: String) -> Bool {\n        queue.sync {\n            return cache.verifiedFingerprints.contains(fingerprint)\n        }\n    }\n    \n    func getVerifiedFingerprints() -> Set<String> {\n        queue.sync {\n            return cache.verifiedFingerprints\n        }\n    }\n\n    var debugNicknameIndex: [String: Set<String>] {\n        queue.sync { cache.nicknameIndex }\n    }\n\n    func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {\n        queue.sync { ephemeralSessions[peerID] }\n    }\n\n    func debugLastInteraction(for fingerprint: String) -> Date? {\n        queue.sync { cache.lastInteractions[fingerprint] }\n    }\n}\n"
  },
  {
    "path": "bitchat/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>CFBundleDevelopmentRegion</key>\n\t<string>$(DEVELOPMENT_LANGUAGE)</string>\n\t<key>CFBundleDisplayName</key>\n\t<string>bitchat</string>\n\t<key>CFBundleExecutable</key>\n\t<string>$(EXECUTABLE_NAME)</string>\n\t<key>CFBundleIdentifier</key>\n\t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n\t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\t<key>CFBundleName</key>\n\t<string>$(PRODUCT_NAME)</string>\n\t<key>CFBundlePackageType</key>\n\t<string>APPL</string>\n\t<key>CFBundleShortVersionString</key>\n\t<string>$(MARKETING_VERSION)</string>\n\t<key>CFBundleURLTypes</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>CFBundleURLSchemes</key>\n\t\t\t<array>\n\t\t\t\t<string>bitchat</string>\n\t\t\t</array>\n\t\t</dict>\n\t</array>\n\t<key>CFBundleVersion</key>\n\t<string>$(CURRENT_PROJECT_VERSION)</string>\n\t<key>LSMinimumSystemVersion</key>\n\t<string>$(MACOSX_DEPLOYMENT_TARGET)</string>\n\t<key>NSBluetoothAlwaysUsageDescription</key>\n\t<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>\n\t<key>NSBluetoothPeripheralUsageDescription</key>\n\t<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>\n\t<key>NSCameraUsageDescription</key>\n\t<string>bitchat uses the camera to scan QR codes to verify peers.</string>\n\t<key>NSPhotoLibraryUsageDescription</key>\n\t<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>\n\t<key>NSMicrophoneUsageDescription</key>\n\t<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>\n\t<key>NSLocationWhenInUseUsageDescription</key>\n\t<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>\n\t<key>UIBackgroundModes</key>\n\t<array>\n\t\t<string>bluetooth-central</string>\n\t\t<string>bluetooth-peripheral</string>\n\t</array>\n\t<key>UILaunchStoryboardName</key>\n\t<string>LaunchScreen</string>\n\t<key>UIRequiresFullScreen</key>\n\t<true/>\n\t<key>UISupportedInterfaceOrientations</key>\n\t<array>\n\t\t<string>UIInterfaceOrientationPortrait</string>\n\t</array>\n</dict>\n</plist>\n"
  },
  {
    "path": "bitchat/LaunchScreen.storyboard",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB\" version=\"3.0\" toolsVersion=\"22155\" targetRuntime=\"iOS.CocoaTouch\" propertyAccessControl=\"none\" useAutolayout=\"YES\" launchScreen=\"YES\" useTraitCollections=\"YES\" useSafeAreas=\"YES\" colorMatched=\"YES\" initialViewController=\"01J-lp-oVM\">\n    <device id=\"retina6_12\" orientation=\"portrait\" appearance=\"light\"/>\n    <dependencies>\n        <deployment identifier=\"iOS\"/>\n        <plugIn identifier=\"com.apple.InterfaceBuilder.IBCocoaTouchPlugin\" version=\"22131\"/>\n        <capability name=\"Safe area layout guides\" minToolsVersion=\"9.0\"/>\n        <capability name=\"documents saved in the Xcode 8 format\" minToolsVersion=\"8.0\"/>\n    </dependencies>\n    <scenes>\n        <!--View Controller-->\n        <scene sceneID=\"EHf-IW-A2E\">\n            <objects>\n                <viewController id=\"01J-lp-oVM\" sceneMemberID=\"viewController\">\n                    <view key=\"view\" contentMode=\"scaleToFill\" id=\"Ze5-6b-2t3\">\n                        <rect key=\"frame\" x=\"0.0\" y=\"0.0\" width=\"393\" height=\"852\"/>\n                        <autoresizingMask key=\"autoresizingMask\" widthSizable=\"YES\" heightSizable=\"YES\"/>\n                        <subviews>\n                            <label opaque=\"NO\" clipsSubviews=\"YES\" userInteractionEnabled=\"NO\" contentMode=\"left\" horizontalHuggingPriority=\"251\" verticalHuggingPriority=\"251\" text=\"bitchat\" textAlignment=\"center\" lineBreakMode=\"middleTruncation\" baselineAdjustment=\"alignBaselines\" minimumFontSize=\"18\" translatesAutoresizingMaskIntoConstraints=\"NO\" id=\"GJd-Yh-RWb\">\n                                <rect key=\"frame\" x=\"0.0\" y=\"403.66666666666669\" width=\"393\" height=\"45\"/>\n                                <fontDescription key=\"fontDescription\" name=\"Menlo-Regular\" family=\"Menlo\" pointSize=\"38\"/>\n                                <color key=\"textColor\" red=\"0.0\" green=\"1.0\" blue=\"0.0\" alpha=\"1\" colorSpace=\"custom\" customColorSpace=\"sRGB\"/>\n                                <nil key=\"highlightedColor\"/>\n                            </label>\n                        </subviews>\n                        <viewLayoutGuide key=\"safeArea\" id=\"Bcu-3y-fUS\"/>\n                        <color key=\"backgroundColor\" white=\"0.0\" alpha=\"1\" colorSpace=\"custom\" customColorSpace=\"genericGamma22GrayColorSpace\"/>\n                        <constraints>\n                            <constraint firstItem=\"GJd-Yh-RWb\" firstAttribute=\"centerY\" secondItem=\"Ze5-6b-2t3\" secondAttribute=\"centerY\" id=\"5cJ-9S-tgC\"/>\n                            <constraint firstItem=\"GJd-Yh-RWb\" firstAttribute=\"leading\" secondItem=\"Bcu-3y-fUS\" secondAttribute=\"leading\" id=\"Q5M-cg-NOt\"/>\n                            <constraint firstItem=\"GJd-Yh-RWb\" firstAttribute=\"trailing\" secondItem=\"Bcu-3y-fUS\" secondAttribute=\"trailing\" id=\"XAW-aL-0N7\"/>\n                        </constraints>\n                    </view>\n                </viewController>\n                <placeholder placeholderIdentifier=\"IBFirstResponder\" id=\"iYj-Kq-Ea1\" userLabel=\"First Responder\" sceneMemberID=\"firstResponder\"/>\n            </objects>\n            <point key=\"canvasLocation\" x=\"53\" y=\"375\"/>\n        </scene>\n    </scenes>\n</document>\n"
  },
  {
    "path": "bitchat/Localizable.xcstrings",
    "content": "{\n  \"sourceLanguage\" : \"en\",\n  \"strings\" : {\n    \"%@\" : {\n      \"comment\" : \"Non-localizable symbol used in code\",\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@\"\n          }\n        }\n      },\n      \"shouldTranslate\" : false\n    },\n    \"%@ active\" : {\n      \"comment\" : \"A label at the bottom of the people list sheet showing the number of active users.\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ نشطين\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ সক্রিয়\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ aktiv\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ active\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ activos\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ aktibo\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ actifs\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ פעילים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ सक्रिय\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ aktif\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ attivi\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ 人がアクティブ\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"활성 사용자 %@명\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ aktif\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ सक्रिय\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ actief\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ aktywni\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ ativos\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ ativos\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ активных\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ aktiva\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ செயலில்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ กำลังใช้งาน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ aktif\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ активних\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ فعال\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ đang hoạt động\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"活跃用户 %@ 名\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"活躍使用者 %@ 位\"\n          }\n        }\n      }\n    },\n    \"app_info.app_name\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat\"\n          }\n        }\n      }\n    },\n    \"app_info.close\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إغلاق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বন্ধ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"schließen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"close\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cerrar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"isara\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fermer\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"סגור\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"बंद करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tutup\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chiudi\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"閉じる\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"닫기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tutup\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"बन्द\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sluiten\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zamknij\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fechar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fechar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"закрыть\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stäng\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"மூடு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ปิด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kapat\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"закрити\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بند کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đóng\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"关闭\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"關閉\"\n          }\n        }\n      }\n    },\n    \"app_info.done\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تم\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"সম্পন্ন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FERTIG\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"DONE\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"LISTO\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"TAPOS\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"TERMINÉ\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"בוצע\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"समाप्त\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"SELESAI\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FATTO\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"完了\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"확인\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"SELESAI\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सम्पन्न\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"KLAAR\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"GOTOWE\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"CONCLUÍDO\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"CONCLUÍDO\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ГОТОВО\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"KLART\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"முடிந்தது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เสร็จสิ้น\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"BİTTİ\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ГОТОВО\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مکمل\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"XONG\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"完成\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"完成\"\n          }\n        }\n      }\n    },\n    \"app_info.features.encryption.description\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"الرسائل الخاصة مشفرة ببروتوكول noise\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"গোপন বার্তাগুলো নোইজ প্রোটোকলের মাধ্যমে এনক্রিপ্ট করা হয়\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"private nachrichten werden mit dem noise-protokoll verschlüsselt\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"private messages encrypted with noise protocol\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mensajes privados cifrados con el protocolo Noise\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ang mga pribadong mensahe ay ini-encrypt gamit ang Noise protocol\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"messages privés chiffrés avec le protocole noise\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הודעות פרטיות מוצפנות בפרוטוקול noise\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नॉइज़ प्रोटोकॉल से निजी संदेश एन्क्रिप्ट किए जाते हैं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pesan pribadi dienkripsi dengan protokol noise\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"messaggi privati cifrati con il protocollo noise\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"プライベートメッセージはnoiseプロトコルで暗号化されます\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"비공개 메시지가 노이즈 프로토콜로 암호화됩니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pesan pribadi dienkripsi dengan protokol noise\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"व्यक्तिगत सन्देशहरू noise प्रोटोकलले सङ्केत गर्छ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"privéberichten worden versleuteld met het Noise-protocol\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"prywatne wiadomości są szyfrowane protokołem Noise\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mensagens privadas encriptadas com o protocolo Noise\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mensagens privadas criptografadas com o protocolo noise\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"личные сообщения шифруются протоколом noise\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"privata meddelanden krypteras med Noise-protokollet\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"தனிப்பட்ட செய்திகள் Noise நெறிமுறையால் குறியாக்கம் செய்யப்படுகின்றன\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ข้อความส่วนตัวถูกเข้ารหัสด้วยโปรโตคอล Noise\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"özel mesajlar noise protokolü ile şifrelenir\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"приватні повідомлення шифруються протоколом noise\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نجی پیغامات Noise پروٹوکول سے خفیہ کیے جاتے ہیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tin nhắn riêng tư được mã hóa bằng giao thức Noise\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"私密消息使用 noise 协议加密\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"私密訊息使用 noise 協議加密\"\n          }\n        }\n      }\n    },\n    \"app_info.features.encryption.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تشفير طرف لطرف\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এন্ড-টু-এন্ড এনক্রিপশন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"end-to-end-verschlüsselung\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"end-to-end encryption\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cifrado de extremo a extremo\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"end-to-end na pag-encrypt\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chiffrement de bout en bout\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הצפנה מקצה לקצה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एंड-टू-एंड एन्क्रिप्शन\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enkripsi ujung ke ujung\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"crittografia end-to-end\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"エンドツーエンド暗号\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"종단간 암호화\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enkripsi ujung ke ujung\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्ड-टु-एन्ड सङ्केत\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"end-to-end-versleuteling\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"szyfrowanie end-to-end\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encriptação ponta a ponta\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"criptografia ponto a ponto\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"сквозное шифрование\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ända-till-ände-kryptering\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"முற்றிலும் குறியாக்கம்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"การเข้ารหัสปลายทางถึงปลายทาง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"uçtan uca şifreleme\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"скрізьове шифрування\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"اینڈ ٹو اینڈ انکرپشن\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mã hóa đầu cuối\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"端到端加密\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"端到端加密\"\n          }\n        }\n      }\n    },\n    \"app_info.features.extended_range.description\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"يُعاد تمرير الرسائل بين الأقران لتصل لمسافات أبعد\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বার্তাগুলো সহ-পিয়ারদের মাধ্যমে রিলে হয়ে দূরেও পৌঁছে যায়\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nachrichten werden zwischen peers weitergeleitet und reichen weiter\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"messages relay through peers, going the distance\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"los mensajes se retransmiten entre pares y llegan lejos\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ipinapasa ang mga mensahe sa mga peer para makarating sa malalayo\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"messages relayés entre pairs pour aller plus loin\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הודעות משודרות בין עמיתים ומגיעות רחוק יותר\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"संदेश साथियों के माध्यम से रिले होकर दूर तक पहुँचते हैं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pesan diteruskan antar peer sehingga jangkauannya lebih jauh\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i messaggi vengono inoltrati tra peer per arrivare più lontano\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"メッセージはピア間でリレーされより遠くに届きます\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"메시지가 피어를 통해 중계되어 더 멀리 도달합니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pesan diteruskan antar peer sehingga jangkauannya lebih jauh\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सन्देशहरू सहकर्मीमार्फत रिले भएर टाढासम्म पुग्छन्\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"berichten worden via peers doorgestuurd om verder te reiken\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wiadomości są przekazywane przez peerów, aby dotrzeć dalej\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mensagens retransmitidas entre pares para chegar mais longe\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mensagens retransmitidas entre pares para alcançar mais longe\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"сообщения ретранслируются между пирами и уходят дальше\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"meddelanden vidarebefordras via peers för att nå längre\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"செய்திகள் துணை இணைப்புகளின் மூலம் பரிமாறி தூரம் சென்றடைகின்றன\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ข้อความส่งต่อผ่านเพียร์เพื่อไปได้ไกลขึ้น\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesajlar eşler üzerinden aktarılarak uzağa ulaşır\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"повідомлення ретранслюються між пірами й долітають далі\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"پیغامات ہم منصبوں کے ذریعے آگے بڑھا کر دور تک پہنچتے ہیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tin nhắn được chuyển tiếp qua các nút ngang hàng để đi xa hơn\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"消息通过同伴中继，传得更远\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"訊息透過同伴中繼，傳得更遠\"\n          }\n        }\n      }\n    },\n    \"app_info.features.extended_range.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نطاق ممتد\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বর্ধিত পরিসর\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"erweiterte reichweite\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"extended range\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"alcance ampliado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mas malawak na saklaw\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"portée étendue\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"טווח מורחב\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"विस्तारित दायरा\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"jangkauan diperluas\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"portata estesa\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"拡張レンジ\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"확장된 범위\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"jangkauan diperluas\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"विस्तारित पहुँच\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"groter bereik\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"większy zasięg\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"alcance alargado\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"alcance estendido\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"расширенный радиус\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"utökat räckvidd\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"விரிந்த வரம்பு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ระยะสื่อสารที่กว้างขึ้น\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"genişletilmiş menzil\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"розширена дальність\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"وسیع دائرہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"phạm vi mở rộng\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"扩展范围\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"擴展範圍\"\n          }\n        }\n      }\n    },\n    \"app_info.features.favorites.description\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تلقَّ تنبيهات عندما ينضم أحباؤك\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"আপনার প্রিয় মানুষ যোগ দিলে বিজ্ঞপ্তি পান\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"erhalte hinweise, wenn deine lieblingsmenschen online kommen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"get notified when your favorite people join\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"recibe avisos cuando tus personas favoritas se conecten\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tumanggap ng abiso kapag sumali ang paborito mong mga tao\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"reçois une alerte quand tes personnes favorites arrivent\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"קבל התראות כשהאנשים המועדפים שלך מצטרפים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"जब आपके पसंदीदा लोग जुड़ें तो सूचना पाएं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dapatkan notifikasi saat orang favoritmu bergabung\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ricevi avvisi quando entrano le tue persone preferite\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"お気に入りの人が参加したら通知を受け取れます\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"즐겨찾는 사용자가 참여하면 알림을 받습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dapatkan notifikasi saat orang favoritmu bergabung\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"तिम्रा मनपर्ने मानिस जोडिएपछि सूचनाहरू पाऊ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ontvang meldingen wanneer je favoriete mensen meedoen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"otrzymuj powiadomienia, gdy dołączają twoje ulubione osoby\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"recebe notificações quando as tuas pessoas favoritas entram\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"receba avisos quando suas pessoas favoritas entrarem\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"получай уведомления, когда подключаются любимые люди\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"få aviseringar när dina favoriter ansluter\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"உங்களுக்குப் பிரியமானவர்கள் சேர்ந்தவுடன் அறிவிப்பு பெறுங்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"รับการแจ้งเตือนเมื่อคนโปรดของคุณเข้าร่วม\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favori kişileriniz katıldığında bildirim alın\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"отримуй сповіщення, коли підключаються улюблені люди\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"جب آپ کے پسندیدہ لوگ شامل ہوں تو اطلاع پائیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nhận thông báo khi những người yêu thích của bạn tham gia\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"你喜欢的人加入时立刻提醒\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"你喜歡的人加入時立刻提醒\"\n          }\n        }\n      }\n    },\n    \"app_info.features.favorites.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"المفضلة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"প্রিয়সমূহ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favoriten\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favorites\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favoritos\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mga paborito\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favoris\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מועדפים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पसंदीदा\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favorit\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"preferiti\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"お気に入り\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"즐겨찾기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favorit\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मनपर्ने\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favorieten\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ulubione\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favoritos\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favoritos\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"избранное\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favoriter\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சிறப்புப் பட்டியல்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"รายการโปรด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favoriler\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"вибране\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"پسندیدہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"yêu thích\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"收藏\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"收藏\"\n          }\n        }\n      }\n    },\n    \"app_info.features.geohash.description\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"قنوات geohash للدردشة مع أشخاص قريبين عبر مرحلات لامركزية مجهولة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ডিসেন্ট্রালাইজড বেনামি রিলে দিয়ে কাছাকাছি অঞ্চলের মানুষের সাথে কথা বলার জন্য জিওহ্যাশ চ্যানেল\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash-kanäle zum chatten mit menschen in der nähe über dezentrale anonyme relays\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash channels to chat with people in nearby regions over decentralized anonymous relays\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canales geohash para chatear con personas en regiones cercanas a través de relays descentralizados anónimos\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mga geohash channel para makipag-chat sa mga tao sa karatig na lugar sa pamamagitan ng mga desentralisado at anonimong relay\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canaux geohash pour discuter avec des personnes proches via des relais décentralisés anonymes\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ערוצי geohash לשיחה עם אנשים קרובים דרך ממסרים אנונימיים מבוזרים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"विकेंद्रीकृत गुमनाम रिले के माध्यम से आसपास के क्षेत्रों के लोगों से चैट करने के लिए जियोहैश चैनल\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kanal geohash untuk ngobrol dengan orang di wilayah sekitar lewat relay anonim terdesentralisasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canali geohash per chattare con persone vicine tramite relay anonimi decentralizzati\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohashチャンネルで近くの人と匿名分散リレー越しにチャット\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"분산형 익명 릴레이를 통해 geohash 채널에서 주변 지역의 사람들과 대화할 수 있습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kanal geohash untuk ngobrol dengan orang di wilayah sekitar lewat relay anonim terdesentralisasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash च्यानलहरूले नजिकका व्यक्तिसँग विकेन्द्रित गोप्य रिलेबाट कुराकानी गर्न मद्दत गर्छ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash-kanalen om te chatten met mensen in de buurt via gedecentraliseerde, anonieme relais\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kanały geohash do rozmów z osobami w pobliżu przez zdecentralizowane, anonimowe przekaźniki\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canais geohash para conversar com pessoas em regiões próximas através de relés descentralizados anónimos\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canais geohash para conversar com pessoas em regiões próximas por relays descentralizados anônimos\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"каналы geohash для чата с людьми поблизости через децентрализованные анонимные реле\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash-kanaler för att chatta med folk i närheten via decentraliserade, anonyma reläer\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"மையமற்ற மறை நெட்வொர்க் ரிலேக்கள் மூலம் அருகிலுள்ள பகுதிகளில் உள்ளவர்களுடன் பேச geohash சேனல்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ช่อง geohash สำหรับพูดคุยกับคนในพื้นที่ใกล้เคียงผ่านรีเลย์แบบกระจายศูนย์และไม่ระบุตัว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"merkeziyetsiz anonim röleler üzerinden yakın bölgelerdeki insanlarla sohbet etmek için geohash kanalları\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"канали geohash для спілкування з людьми поблизу через децентралізовані анонімні ретранслятори\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"غیر مرکزی گمنام ریلوں کے ذریعے قریب کے لوگوں سے بات کرنے کیلئے geohash چینلز\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash 频道让你通过去中心化匿名中继与附近地区的人聊天\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash 頻道讓你透過去中心化匿名中繼與附近地區的人聊天\"\n          }\n        }\n      }\n    },\n    \"app_info.features.geohash.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"قنوات محلية\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"স্থানীয় চ্যানেল\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lokale kanäle\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"local channels\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canales locales\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mga lokal na channel\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canaux locaux\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ערוצים מקומיים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"स्थानीय चैनल\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kanal lokal\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canali locali\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ローカルチャンネル\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"지역 채널\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kanal lokal\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"स्थानीय च्यानल\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lokale kanalen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kanały lokalne\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canais locais\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canais locais\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"локальные каналы\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lokala kanaler\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"உள்ளூர் சேனல்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ช่องท้องถิ่น\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"yerel kanallar\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"локальні канали\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مقامی چینلز\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kênh địa phương\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"本地频道\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"本地頻道\"\n          }\n        }\n      }\n    },\n    \"app_info.features.mentions.description\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"استخدم @nickname لتنبيه أشخاص محددين\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"নির্দিষ্ট কাউকে জানানোর জন্য @nickname ব্যবহার করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nutze @nickname, um bestimmte personen zu benachrichtigen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"use @nickname to notify specific people\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"usa @nickname para avisar a personas concretas\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gumamit ng @nickname para abisuhan ang partikular na tao\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"utilise @nickname pour avertir des personnes précises\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"השתמש ב-@nickname כדי להתריע לאנשים ספציפיים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"विशेष लोगों को सूचित करने के लिए @nickname उपयोग करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pakai @nickname untuk memberi tahu orang tertentu\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"usa @nickname per avvisare persone specifiche\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"@nicknameで特定の人に通知\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"@닉네임을 사용하여 특정 사람에게 알림을 보낼 수 있습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"guna @nickname untuk memberi tahu orang tertentu\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"विशेष व्यक्तिलाई सूचित गर्न @nickname प्रयोग गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gebruik @nickname om een specifiek persoon te pingen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"użyj @nickname, aby powiadomić konkretną osobę\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"usa @nickname para avisar pessoas específicas\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"use @nickname para notificar pessoas específicas\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"используй @nickname, чтобы уведомить конкретных людей\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"använd @nickname för att meddela en specifik person\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறிப்பிட்டவரை அறிவிக்க @nickname பயன்படுத்துங்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ใช้ @nickname เพื่อแจ้งเตือนบุคคลเฉพาะ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"belirli kişileri bildirmek için @nickname kullanın\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"використовуй @nickname, щоб сповістити конкретних людей\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"کسی مخصوص شخص کو خبردار کرنے کیلئے @nickname استعمال کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dùng @nickname để thông báo cho người cụ thể\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"使用 @nickname 提醒特定的人\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"使用 @nickname 提醒特定的人\"\n          }\n        }\n      }\n    },\n    \"app_info.features.mentions.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إشارات\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"মেনশন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"erwähnungen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mentions\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menciones\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mga banggit\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mentions\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אזכורים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मेंशन\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mention\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menzioni\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"メンション\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"멘션\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mention\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"उल्लेख\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"vermeldingen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wzmianki\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menções\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menções\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"упоминания\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"omnämnanden\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"மேற்கோள்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"การกล่าวถึง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bahsetmeler\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"згадки\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ذکر\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nhắc tới\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"提及\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"提及\"\n          }\n        }\n      }\n    },\n    \"app_info.features.offline.description\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"يعمل بدون إنترنت باستخدام bluetooth منخفض الطاقة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্লুটুথ লো এনার্জি ব্যবহার করে ইন্টারনেট ছাড়াই কাজ করে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"funktioniert ohne internet per bluetooth low energy\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"works without internet using Bluetooth low energy\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"funciona sin internet utilizando Bluetooth de bajo consumo\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gumagana kahit walang internet gamit ang Bluetooth Low Energy\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fonctionne sans internet avec le bluetooth basse énergie\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"עובד בלי אינטרנט באמצעות bluetooth בתצריכת אנרגיה נמוכה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ब्लूटूथ लो एनर्जी से इंटरनेट बिना भी काम करता है\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bekerja tanpa internet memakai bluetooth low energy\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"funziona senza internet usando bluetooth a basso consumo\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth low energyでインターネットなしでも動作\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"저전력 bluetooth를 사용하여 인터넷 없이 작동합니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"berfungsi tanpa internet menggunakan bluetooth low energy\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth low energy प्रयोग गरेर इन्टरनेट बिना काम गर्छ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"werkt zonder internet met Bluetooth Low Energy\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"działa bez internetu dzięki Bluetooth Low Energy\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"funciona sem internet usando Bluetooth de baixo consumo\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"funciona sem internet usando bluetooth de baixa energia\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"работает без интернета через bluetooth low energy\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fungerar utan internet med Bluetooth Low Energy\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bluetooth குறைந்த மின்சாரத்தைப் பயன்படுத்தி இணையமின்றி இயங்குகிறது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ทำงานได้แม้ไม่มีอินเทอร์เน็ตด้วย Bluetooth พลังงานต่ำ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bluetooth Low Energy kullanarak internet olmadan çalışır\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"працює без інтернету через bluetooth low energy\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bluetooth Low Energy کے ساتھ بغیر انٹرنیٹ کے کام کرتا ہے\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hoạt động không cần internet bằng Bluetooth năng lượng thấp\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"利用低功耗 bluetooth 离线工作\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"利用低功耗 bluetooth 離線工作\"\n          }\n        }\n      }\n    },\n    \"app_info.features.offline.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تواصل بدون اتصال\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"অফলাইন যোগাযোগ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"offline-kommunikation\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"offline communication\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"comunicación sin conexión\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"offline na komunikasyon\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"communication hors ligne\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"תקשורת לא מקוונת\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ऑफलाइन संचार\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"komunikasi offline\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"comunicazione offline\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"オフライン通信\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"오프라인 통신\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"komunikasi offline\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अफलाइन सञ्चार\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"offline communicatie\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"komunikacja offline\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"comunicação offline\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"comunicação offline\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"офлайн-связь\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"offlinekommunikation\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ஆஃப்லைன் தொடர்பு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"การสื่อสารออฟไลน์\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"çevrimdışı iletişim\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"офлайн-зв'язок\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"آف لائن مواصلات\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"liên lạc ngoại tuyến\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"离线通信\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"離線通信\"\n          }\n        }\n      }\n    },\n    \"app_info.features.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مزايا\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বৈশিষ্ট্য\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FUNKTIONEN\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FEATURES\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FUNCIONES\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"MGA TAMPOK\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FONCTIONNALITÉS\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"יכולות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"विशेषताएँ\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FITUR\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FUNZIONI\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"機能\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"기능\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FITUR\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"विशेषता\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FUNCTIES\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FUNKCJE\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FUNCIONALIDADES\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"RECURSOS\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ВОЗМОЖНОСТИ\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"FUNKTIONER\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அம்சங்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ฟีเจอร์\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ÖZELLİKLER\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"МОЖЛИВОСТІ\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"خصوصیات\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"TÍNH NĂNG\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"功能\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"功能\"\n          }\n        }\n      }\n    },\n    \"app_info.how_to_use.change_channels\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• اضغط #mesh لتغيير القناة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• চ্যানেল বদলাতে #mesh ট্যাপ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tippe auf #mesh, um den kanal zu wechseln\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tap #mesh to change channels\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• toca #mesh para cambiar de canal\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• i-tap ang #mesh para magpalit ng channel\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tape sur #mesh pour changer de canal\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• הקש על #mesh כדי להחליף ערוץ\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• चैनल बदलने के लिए #mesh टैप करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ketuk #mesh untuk ganti kanal\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tocca #mesh per cambiare canale\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• #meshをタップしてチャンネルを切り替え\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• #mesh를 탭하여 채널을 변경합니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ketuk #mesh untuk ganti kanal\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• च्यानल बदल्न #mesh ट्याप गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tik op #mesh om van kanaal te wisselen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• stuknij #mesh, aby zmienić kanał\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• toca em #mesh para mudar de canal\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• toque #mesh para trocar de canal\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• нажми #mesh, чтобы сменить канал\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tryck på #mesh för att byta kanal\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• சேனலை மாற்ற #mesh ஐத் தட்டவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• แตะ #mesh เพื่อเปลี่ยนช่อง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• kanalı değiştirmek için #mesh'e dokunun\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• торкнися #mesh, щоб змінити канал\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• چینل بدلنے کیلئے #mesh پر ٹیپ کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• chạm #mesh để đổi kênh\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 轻点 #mesh 切换频道\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 輕點 #mesh 切換頻道\"\n          }\n        }\n      }\n    },\n    \"app_info.how_to_use.clear_chat\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• اضغط الدردشة ثلاث مرات للمسح\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• চ্যাট মুছতে তিনবার ট্যাপ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tippe den chat dreimal, um ihn zu leeren\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• triple-tap chat to clear\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• toca tres veces el chat para limpiarlo\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• i-triple tap ang chat para linisin\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tape trois fois sur le chat pour le vider\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• הקש שלוש פעמים על הצ'אט כדי לנקות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• चैट साफ़ करने के लिए तीन बार टैप करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ketuk chat tiga kali untuk menghapus\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tocca tre volte la chat per svuotarla\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• チャットを3回タップするとクリア\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 대화를 세 번 탭하여 삭제합니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ketuk chat tiga kali untuk menghapus\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• च्याट खाली गर्न तीन पटक ट्याप गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tik drie keer op de chat om te wissen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• stuknij okno czatu trzykrotnie, aby wyczyścić\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• toca três vezes no chat para o limpar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• toque o chat três vezes para limpar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• тройной тап по чату очистит его\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tryck tre gånger i chatten för att rensa\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• உரையாடலை அழிக்க மூன்று முறை தட்டுங்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• แตะหน้าจอแชทสามครั้งเพื่อล้างข้อความ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• sohbeti temizlemek için sohbete üç kez dokunun\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• торкни чат тричі, щоб очистити\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• چیٹ صاف کرنے کیلئے تین بار ٹیپ کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• chạm ba lần vào khung chat để xóa\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 三击聊天即可清除\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 三擊聊天即可清除\"\n          }\n        }\n      }\n    },\n    \"app_info.how_to_use.commands\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• اكتب / لعرض الأوامر\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• কমান্ডের জন্য / টাইপ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tippe /, um befehle zu sehen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• type / for commands\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• escribe / para ver los comandos\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• mag-type ng / para sa mga utos\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tape / pour voir les commandes\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• הקלד / כדי לראות פקודות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• कमांड के लिए / टाइप करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ketik / untuk melihat perintah\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• digita / per vedere i comandi\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• /を入力してコマンド表示\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• /를 입력하여 명령어를 사용합니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ketik / untuk melihat perintah\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• आदेशहरू हेर्न / टाइप गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• typ / voor opdrachten\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• wpisz /, aby zobaczyć polecenia\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• escreve / para ver os comandos\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• digite / para ver comandos\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• введи /, чтобы увидеть команды\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• skriv / för kommandon\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• கட்டளைகளை காண / என்பதைத் தட்டச்சுங்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• พิมพ์ / เพื่อแสดงคำสั่ง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• komutlar için / yazın\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• введи /, щоб побачити команди\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• کمانڈز کیلئے / ٹائپ کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• gõ / để xem lệnh\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 输入 / 查看指令\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 輸入 / 查看指令\"\n          }\n        }\n      }\n    },\n    \"app_info.how_to_use.open_sidebar\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• اضغط أيقونة الأشخاص لفتح الشريط الجانبي\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• সাইডবার খুলতে মানুষ আইকনে ট্যাপ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tippe auf das personen-icon, um die seitenleiste zu öffnen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tap people icon for sidebar\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• toca el ícono de personas para abrir la barra lateral\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• i-tap ang icon ng tao para buksan ang sidebar\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tape sur l'icône personnes pour ouvrir la barre latérale\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• הקש על אייקון האנשים כדי לפתוח סרגל צד\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• साइडबार खोलने के लिए लोगों वाले आइकन पर टैप करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ketuk ikon orang untuk membuka sidebar\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tocca l'icona persone per aprire la barra laterale\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 人アイコンをタップしてサイドバーを開く\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 사람 아이콘을 탭하여 사이드바를 엽니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ketuk ikon orang untuk membuka sidebar\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• साइडबार खोल्न मान्छे आइकन ट्याप गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tik op het personen-icoon om de zijbalk te openen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• stuknij ikonę osób, aby otworzyć panel boczny\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• toca no ícone das pessoas para abrir a barra lateral\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• toque o ícone de pessoas para abrir a barra lateral\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• нажми на иконку людей, чтобы открыть боковое меню\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tryck på personikonen för att öppna sidomenyn\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• பக்கப்பட்டியைத் திறக்க மனிதர் சின்னத்தைத் தட்டுங்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• แตะไอคอนคนเพื่อเปิดแถบด้านข้าง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• kenar çubuğunu açmak için insan simgesine dokunun\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• торкни піктограму людей, щоб відкрити бічну панель\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• سائیڈ بار کھولنے کیلئے لوگوں کا آئیکن ٹیپ کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• chạm biểu tượng người để mở thanh bên\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 轻点人物图标打开侧栏\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 輕點人物圖示打開側欄\"\n          }\n        }\n      }\n    },\n    \"app_info.how_to_use.set_nickname\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• اضبط لقبك بلمسه\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• আপনার ডাকনামে ট্যাপ করে সেট করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tippe auf deinen nickname, um ihn zu ändern\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• set your nickname by tapping it\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• define tu apodo tocándolo\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• itakda ang iyong palayaw sa pag-tap dito\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• règle ton pseudo en le touchant\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• הקש על הכינוי שלך כדי לעדכן\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• अपना उपनाम टैप करके सेट करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• atur nama panggilanmu dengan mengetuknya\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• imposta il tuo nickname toccandolo\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ニックネームをタップして設定\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 닉네임을 탭하여 설정합니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• atur nama panggilanmu dengan mengetuknya\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• आफ्नो उपनाममा ट्याप गरेर मिलाऊ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• stel je bijnaam in door erop te tikken\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ustaw swój pseudonim, stukając go\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• define o teu apelido tocando nele\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• defina seu apelido tocando nele\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• коснись своего ника, чтобы изменить его\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ställ in ditt smeknamn genom att trycka på det\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• உங்கள் புனைப் பெயரைத் தட்டிக் கொண்டு அமைக்கவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ตั้งชื่อเล่นโดยแตะที่ชื่อของคุณ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• takma adınızı üzerine dokunarak ayarlayın\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• змінюй свій нік, торкаючись його\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• اپنا نک نیم سیٹ کرنے کیلئے اس پر ٹیپ کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• đặt biệt danh bằng cách chạm vào tên bạn\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 轻点昵称即可设置\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 輕點暱稱即可設定\"\n          }\n        }\n      }\n    },\n    \"app_info.how_to_use.start_dm\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• اضغط اسم القرين لبدء رسائل خاصة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ডিএম শুরু করতে পিয়ারের নাম ট্যাপ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tippe auf den namen eines peers, um eine pn zu starten\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tap a peer's name to start a DM\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• toca el nombre de un participante para iniciar un MD\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• i-tap ang pangalan ng peer para magsimula ng DM\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tape sur le nom d'un pair pour démarrer un mp\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• הקש על שם עמית כדי להתחיל הודעה פרטית\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• किसी पीयर का नाम टैप करके DM शुरू करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ketuk nama peer untuk mulai dm\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tocca il nome di un peer per avviare un dm\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ピアの名前をタップしてdm開始\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 피어의 이름을 탭하여 DM을 시작합니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• ketuk nama peer untuk mulai dm\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• dm सुरु गर्न कुनै सहकर्मीको नाम ट्याप गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tik op de naam van een peer om een DM te starten\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• stuknij nazwę peera, aby rozpocząć DM\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• toca no nome de um par para iniciar um DM\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• toque o nome de um par para iniciar um dm\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• нажми имя пользователя, чтобы начать лс\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• tryck på en peers namn för att starta ett DM\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• peer பெயரைத் தட்டி DM தொடங்கவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• แตะชื่อเพียร์เพื่อเริ่ม DM\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• bir eşin adına dokunarak DM başlatın\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• торкни ім'я піра, щоб почати приватний чат\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• DM شروع کرنے کیلئے کسی ہم منصب کے نام پر ٹیپ کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• chạm tên một nút ngang hàng để mở DM\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 轻点同伴名字开始 dm\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"• 輕點同伴名字開始 dm\"\n          }\n        }\n      }\n    },\n    \"app_info.how_to_use.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"طريقة الاستخدام\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্যবহারের নিয়ম\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"SO FUNKTIONIERT'S\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"HOW TO USE\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"CÓMO USARLO\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PAANO GAMITIN\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"MODE D'EMPLOI\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"איך להשתמש\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"उपयोग कैसे करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"CARA PAKAI\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"COME SI USA\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"使い方\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"사용 방법\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"CARA PAKAI\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रयोग गर्ने तरिका\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"GEBRUIKSAANWIJZING\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"JAK UŻYWAĆ\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"COMO USAR\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"COMO USAR\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"КАК ИСПОЛЬЗОВАТЬ\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"SÅ HÄR ANVÄNDER DU\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பயன்படுத்துவது எப்படி\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"วิธีใช้งาน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"NASIL KULLANILIR\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ЯК КОРИСТУВАТИСЯ\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"استعمال کرنے کا طریقہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"CÁCH SỬ DỤNG\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"使用方法\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"使用方法\"\n          }\n        }\n      }\n    },\n    \"app_info.privacy.ephemeral.description\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"يُولد معرف قرين جديد بانتظام\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"নিয়মিত নতুন পিয়ার আইডি তৈরি হয়\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"neue peer-id wird regelmäßig erzeugt\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"new peer ID generated regularly\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nuevo ID de peer generado periódicamente\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"regular na lumilikha ng bagong peer ID\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nouvel id de pair généré régulièrement\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מזהה עמית חדש נוצר באופן קבוע\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नया पीयर आईडी नियमित रूप से बनाया जाता है\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"id peer baru dibuat secara berkala\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nuovo id peer generato regolarmente\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"新しいpeer idが定期的に生成されます\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"새로운 피어 ID가 주기적으로 생성됩니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"id peer baru dibuat secara berkala\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नयाँ peer id नियमित रूपमा सिर्जना हुन्छ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nieuw peer-ID wordt regelmatig aangemaakt\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nowe ID peera generowane regularnie\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"novo ID de par gerado regularmente\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"novo id de peer gerado regularmente\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"новый id пира создаётся регулярно\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nytt peer-ID skapas regelbundet\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"புதிய peer ID வழக்கமாக உருவாக்கப்படுகிறது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"สร้างรหัสเพียร์ใหม่เป็นระยะ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"yeni eş kimliği düzenli olarak oluşturulur\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"новий id піра генерується регулярно\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نیا peer ID باقاعدگی سے بنایا جاتا ہے\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tạo ID nút mới định kỳ\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"定期生成新的 peer id\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"定期生成新的 peer id\"\n          }\n        }\n      }\n    },\n    \"app_info.privacy.ephemeral.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"هوية مؤقتة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"অস্থায়ী পরিচয়\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"flüchtige identität\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ephemeral identity\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"identidad efímera\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"panandaliang pagkakakilanlan\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"identité éphémère\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"זהות זמנית\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अस्थायी पहचान\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"identitas sementara\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"identità effimera\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"一時的なアイデンティティ\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"임시 신원\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"identitas sementara\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"क्षणिक पहिचान\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tijdelijke identiteit\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tymczasowa tożsamość\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"identidade efémera\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"identidade efêmera\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"эфемерная личность\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tillfällig identitet\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"தற்காலிக அடையாளம்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ตัวตนชั่วคราว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geçici kimlik\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ефемерна ідентичність\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"عارضی شناخت\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"danh tính tạm thời\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"临时身份\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"臨時身份\"\n          }\n        }\n      }\n    },\n    \"app_info.privacy.no_tracking.description\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لا خوادم أو حسابات أو جمع بيانات\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"কোনো সার্ভার, অ্যাকাউন্ট বা ডেটা সংগ্রহ নেই\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"keine server, konten oder datensammlung\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no servers, accounts, or data collection\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sin servidores, cuentas ni recopilación de datos\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"walang mga server, account, o pagkuha ng data\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sans serveurs, comptes ni collecte de données\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אין שרתים, חשבונות או איסוף נתונים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"कोई सर्वर, खाते या डेटा संग्रह नहीं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tanpa server, akun, atau pengumpulan data\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"niente server, account o raccolta dati\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"サーバーもアカウントもデータ収集もなし\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"서버와 계정이 없으며 데이터를 수집하지 않습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tanpa server, akun, atau pengumpulan data\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सर्भर, खाताहरू वा तथ्याङ्क सङ्कलन छैन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geen servers, accounts of dataverzameling\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bez serwerów, kont ani zbierania danych\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sem servidores, contas ou recolha de dados\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sem servidores, contas ou coleta de dados\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"без серверов, аккаунтов и сбора данных\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"inga servrar, konton eller datainsamling\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சர்வர்கள் இல்லை, கணக்குகள் இல்லை, தரவுச் சேகரிப்பு இல்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่มีเซิร์ฟเวอร์ บัญชี หรือการเก็บข้อมูล\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sunucu, hesap veya veri toplama yok\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"жодних серверів, обліковок чи збору даних\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"کوئی سرور، اکاؤنٹس یا ڈیٹا جمع نہیں کیا جاتا\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không máy chủ, không tài khoản, không thu thập dữ liệu\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"无服务器、无账号、无数据收集\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"無伺服器、無帳號、無數據收集\"\n          }\n        }\n      }\n    },\n    \"app_info.privacy.no_tracking.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لا تتبع\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ট্র্যাকিং নেই\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kein tracking\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no tracking\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sin seguimiento\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"walang pagsubaybay\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sans suivi\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ללא מעקב\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ट्रैकिंग नहीं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tanpa pelacakan\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"senza tracciamento\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"追跡なし\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"추적 없음\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tanpa pelacakan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ट्र्याकिङ छैन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geen tracking\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"brak śledzenia\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sem rastreio\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sem rastreamento\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"без трекинга\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ingen spårning\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பின்தொடர்வு இல்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่มีการติดตาม\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"izleme yok\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"без відстеження\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ٹریسنگ نہیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không theo dõi\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"无跟踪\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"無跟蹤\"\n          }\n        }\n      }\n    },\n    \"app_info.privacy.panic.description\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"اضغط الشعار ثلاث مرات لمسح كل البيانات فوراً\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"লোগোতে তিনবার ট্যাপ করলে সঙ্গে সঙ্গে সব ডেটা মুছে যাবে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tippe dreimal auf das logo, um alle daten sofort zu löschen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"triple-tap logo to instantly clear all data\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toca el logotipo tres veces para borrar todos los datos al instante\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-tap ang logo nang tatlong beses para agad burahin ang lahat ng data\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tape trois fois sur le logo pour tout effacer instantanément\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הקש על הלוגו שלוש פעמים למחיקת כל הנתונים מייד\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लोगो पर तीन बार टैप करें और तुरंत सारा डेटा साफ़ करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ketuk logo tiga kali untuk langsung menghapus semua data\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tocca il logo tre volte per cancellare subito tutti i dati\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ロゴを3回タップすると全データを即削除\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"로고를 세 번 탭하여 모든 데이터 즉시 삭제할 수 있습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ketuk logo tiga kali untuk langsung menghapus semua data\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लगो तीन पटक ट्याप गर्दा सबै डाटा तुरुन्त मेटिन्छ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tik drie keer op het logo om alle data direct te wissen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stuknij logo trzy razy, aby natychmiast usunąć wszystkie dane\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toca três vezes no logótipo para limpar todos os dados de imediato\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toque o logo três vezes para limpar todos os dados instantaneamente\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"тройной тап по логотипу мгновенно очищает все данные\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tryck tre gånger på logotypen för att radera all data direkt\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"எல்லா தரவையும் உடனடியாக நீக்க லோகோவை மூன்று முறைத் தட்டுங்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"แตะโลโก้สามครั้งเพื่อลบข้อมูลทั้งหมดทันที\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"logoya üç kez dokunun, tüm veriler anında silinsin\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"тричі торкни логотип, щоб миттєво стерти всі дані\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تمام ڈیٹا فوری صاف کرنے کیلئے لوگو پر تین بار ٹیپ کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chạm logo ba lần để xóa toàn bộ dữ liệu ngay lập tức\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"三击标志立即清除全部数据\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"三擊標誌立即清除全部數據\"\n          }\n        }\n      }\n    },\n    \"app_info.privacy.panic.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"وضع الذعر\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"প্যানিক মোড\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"panikmodus\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"panic mode\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"modo pánico\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"panic mode\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mode panique\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מצב בהלה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पैनिक मोड\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mode panik\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"modalità panico\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"パニックモード\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"패닉 모드\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mode panik\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"घबराहट मोड\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"paniekmodus\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tryb paniki\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"modo pânico\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"modo pânico\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"режим паники\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"panikläge\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பதற்ற நிலை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"โหมดฉุกเฉิน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"panik modu\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"режим паніки\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"پینک موڈ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chế độ khẩn cấp\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"紧急模式\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"緊急模式\"\n          }\n        }\n      }\n    },\n    \"app_info.privacy.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"خصوصية\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"গোপনীয়তা\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PRIVATSPHÄRE\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PRIVACY\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PRIVACIDAD\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PRIBASYA\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"CONFIDENTIALITÉ\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"פרטיות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"गोपनीयता\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PRIVASI\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PRIVACY\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"プライバシー\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"개인정보 보호\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PRIVASI\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"गोपनीयता\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PRIVACY\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PRYWATNOŚĆ\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PRIVACIDADE\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PRIVACIDADE\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"КОНФИДЕНЦИАЛЬНОСТЬ\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PRIVAT\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"தனியுரிமை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ความเป็นส่วนตัว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"GİZLİLİK\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"КОНФІДЕНЦІЙНІСТЬ\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"رازداری\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QUYỀN RIÊNG TƯ\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"隐私\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"隱私\"\n          }\n        }\n      }\n    },\n    \"app_info.tagline\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidegroupchat\"\n          }\n        }\n      }\n    },\n    \"app_info.warning.message\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"أمان الرسائل الخاصة لم يتم تدقيقه بالكامل بعد. لا تستخدمها في الحالات الحرجة حتى يختفي هذا التحذير.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্যক্তিগত বার্তার নিরাপত্তা এখনো সম্পূর্ণ অডিট হয়নি। এই সতর্কতা না থাকা পর্যন্ত জরুরি পরিস্থিতিতে ব্যবহার করবেন না।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"die sicherheit privater nachrichten wurde noch nicht vollständig geprüft. nutze sie nicht für kritische situationen, solange dieser hinweis erscheint.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"private message security has not yet been fully audited. do not use for critical situations until this warning disappears.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אבטחת ההודעות הפרטיות עדיין לא נבדקה במלואה. אל תשתמש למצבים קריטיים עד שהאזהרה תיעלם.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"निजी संदेश सुरक्षा का अभी पूरा ऑडिट नहीं हुआ है। यह चेतावनी हटने तक गंभीर स्थितियों में उपयोग न करें।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"keamanan pesan pribadi belum diaudit sepenuhnya. jangan dipakai untuk situasi kritis sampai peringatan ini hilang.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"la sicurezza dei messaggi privati non è stata ancora auditata completamente. non usarli in situazioni critiche finché questo avviso resta.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"プライベートメッセージの安全性はまだ完全に監査されていません。この警告が消えるまで重要な場面では使わないでください。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"비공개 메시지 보안은 아직 완전히 감사받지 않았습니다. 이 경고가 사라질 때까지 중요한 상황에서는 사용하지 마세요.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"keamanan pesan pribadi belum diaudit sepenuhnya. jangan diguna untuk situasi kritis sampai peringatan ini hilang.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"व्यक्तिगत सन्देशको सुरक्षा पूर्ण रूपमा अडिट भएको छैन। यो चेतावनी हट्दासम्म गम्भीर अवस्थामा प्रयोग नगर्नु।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"de beveiliging van privéberichten is nog niet volledig geaudit. gebruik dit niet in kritieke situaties totdat deze melding verdwijnt.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"безопасность приватных сообщений ещё не прошла полный аудит. не используй для критичных случаев, пока предупреждение не исчезнет.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"தனிப்பட்ட செய்தி பாதுகாப்பு இன்னும் முழுமையாக ஆய்வு செய்யப்படவில்லை. இந்த எச்சரிக்கை மறையும் வரை முக்கிய அவசரங்களுக்கு பயன்படுத்தாதீர்கள்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ความปลอดภัยของข้อความส่วนตัวยังไม่ได้รับการตรวจสอบทั้งหมด อย่าใช้ในสถานการณ์วิกฤติจนกว่าคำเตือนนี้จะหายไป\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"özel mesaj güvenliği henüz tamamen denetlenmedi. bu uyarı kaybolana kadar kritik durumlarda kullanmayın.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"безпека приватних повідомлень ще не пройшла повний аудит. не використовуй для критичних ситуацій, поки це попередження не зникне.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نجی پیغامات کی سیکیورٹی کا ابھی مکمل آڈٹ نہیں ہوا۔ اس انتباہ کے ختم ہونے تک اسے اہم حالات میں استعمال نہ کریں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"私信安全尚未完全审计。在此警告消失前不要用于关键情境。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"私信安全尚未完全審計。在此警告消失前不要用於關鍵情境。\"\n          }\n        }\n      }\n    },\n    \"app_info.warning.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تحذير\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"সতর্কতা\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"WARNUNG\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"WARNING\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ADVERTENCIA\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"BABALA\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"AVERTISSEMENT\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אזהרה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"चेतावनी\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PERINGATAN\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"AVVISO\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"警告\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"경고\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PERINGATAN\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"चेतावनी\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"WAARSCHUWING\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OSTRZEŻENIE\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"AVISO\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"AVISO\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ПРЕДУПРЕЖДЕНИЕ\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"VARNING\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"எச்சரிக்கை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"คำเตือน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"UYARI\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ПОПЕРЕДЖЕННЯ\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"انتباہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"CẢNH BÁO\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"警告\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"警告\"\n          }\n        }\n      }\n    },\n    \"close\" : {\n      \"comment\" : \"Button to dismiss fullscreen media viewer\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إغلاق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বন্ধ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"schließen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"close\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cerrar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"isara\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fermer\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"סגור\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"बंद करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tutup\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chiudi\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"閉じる\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"닫기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tutup\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"बन्द\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sluiten\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zamknij\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fechar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fechar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"закрыть\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stäng\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"மூடவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ปิด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kapat\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"закрити\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بند کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đóng\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"关闭\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"關閉\"\n          }\n        }\n      }\n    },\n    \"common.cancel\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إلغاء\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বাতিল\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"abbrechen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cancel\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cancelar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kanselahin\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"annuler\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ביטול\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"रद्द करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"batal\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"annulla\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"キャンセル\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"취소\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"batal\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"रद्द\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"annuleren\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"anuluj\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cancelar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cancelar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"отмена\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"avbryt\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ரத்து\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ยกเลิก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"iptal\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"скасувати\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"منسوخ کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hủy\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"取消\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"取消\"\n          }\n        }\n      }\n    },\n    \"common.close\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إغلاق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বন্ধ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"schließen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"close\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cerrar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"isara\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fermer\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"סגור\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"बंद करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tutup\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chiudi\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"閉じる\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"닫기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tutup\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"बन्द\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sluiten\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zamknij\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fechar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fechar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"закрыть\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stäng\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"மூடு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ปิด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kapat\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"закрити\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بند کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đóng\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"关闭\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"關閉\"\n          }\n        }\n      }\n    },\n    \"common.copy\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نسخ\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"কপি করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kopieren\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"copy\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"copiar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kopyahin\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"copier\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"העתק\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"कॉपी करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"salin\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"copia\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"コピー\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"복사\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"salin\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रतिलिपि\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kopiëren\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kopiuj\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"copiar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"copiar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"копировать\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kopiera\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"நகலெடு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"คัดลอก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kopyala\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"скопіювати\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نقل کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sao chép\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"复制\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"複製\"\n          }\n        }\n      }\n    },\n    \"common.ok\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"موافق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ঠিক আছে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aceptar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ठीक है\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"확인\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ठिक\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சரி\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ตกลง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"TAMAM\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OK\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ٹھیک\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ĐỒNG Ý\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"确定\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"確定\"\n          }\n        }\n      }\n    },\n    \"common.toggle.off\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إيقاف\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বন্ধ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aus\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"off\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"desactivado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"patay\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"désactivé\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"כבוי\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"बंद\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mati\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"spento\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"オフ\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"끄기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mati\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अफ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"uit\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wył.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"desligado\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"desligado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"выкл\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"av\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ஆஃப்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ปิด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kapalı\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"вимк\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بند\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tắt\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"关闭\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"關閉\"\n          }\n        }\n      }\n    },\n    \"common.toggle.on\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تشغيل\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"চালু\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"an\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"on\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"activado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bukas\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"activé\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"פעיל\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"चालू\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nyala\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"acceso\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"オン\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"켜기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nyala\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aan\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wł.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ligado\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ligado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"вкл\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"på\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ஆன்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เปิด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"açık\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"увімк\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"چالو\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bật\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"开启\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"開啟\"\n          }\n        }\n      }\n    },\n    \"common.unknown\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"غير معروف\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"অজানা\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"unbekannt\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"unknown\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"desconocido\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi alam\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"inconnu\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"לא ידוע\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अज्ञात\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak diketahui\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sconosciuto\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"不明\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"알 수 없음\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak diketahui\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अज्ञात\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"onbekend\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nieznane\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"desconhecido\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"desconhecido\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"неизвестно\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"okänt\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அறியவில்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่ทราบ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bilinmiyor\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"невідомо\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نامعلوم\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không rõ\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未知\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未知\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.add_favorite\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إضافة إلى المفضلة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"প্রিয়তে যোগ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zu favoriten hinzufügen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"add to favorites\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"agregar a favoritos\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"idagdag sa mga paborito\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ajouter aux favoris\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הוסף למועדפים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पसंदीदा में जोड़ें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tambah ke favorit\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aggiungi ai preferiti\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"お気に入りに追加\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"즐겨찾기에 추가\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tambah ke favorit\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मनपर्नेमा थप\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aan favorieten toevoegen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dodaj do ulubionych\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"adicionar aos favoritos\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"adicionar aos favoritos\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"добавить в избранное\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lägg till i favoriter\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பிரியப்பட்டதில் சேர்க்கவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เพิ่มในรายการโปรด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favorilere ekle\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"додати до вибраного\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"پسندیدہ میں شامل کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"thêm vào mục yêu thích\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"加入收藏\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"加入收藏\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.available_nostr\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"متاح عبر nostr\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"নোস্টরে উপলব্ধ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verfügbar über nostr\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"available via Nostr\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"disponible vía Nostr\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"magagamit sa Nostr\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"disponible via nostr\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"זמין דרך nostr\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नोस्ट्र पर उपलब्ध\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tersedia melalui nostr\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"disponibile via nostr\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nostrで利用可能\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Nostr를 통해 사용 가능\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tersedia melalui nostr\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nostr मार्फत उपलब्ध\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"beschikbaar via Nostr\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dostępne przez Nostr\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"disponível através do Nostr\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"disponível via nostr\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"доступно через nostr\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tillgänglig via Nostr\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Nostr வழியாக கிடைக்கிறது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ใช้งานได้ผ่าน Nostr\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Nostr üzerinden kullanılabilir\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"доступно через nostr\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Nostr کے ذریعے دستیاب\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"có sẵn qua Nostr\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"通过 Nostr 可用\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"透過 Nostr 可用\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.back_to_main_chat\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"عودة إلى الدردشة الرئيسية\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"মুখ্য চ্যাটে ফিরুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zurück zum hauptchat\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"back to main chat\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"volver al chat principal\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bumalik sa pangunahing chat\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"retour au chat principal\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"חזרה לצ'אט הראשי\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मुख्य चैट पर वापस\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kembali ke chat utama\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"torna alla chat principale\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"メインチャットに戻る\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"메인 대화로 돌아가기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kembali ke chat utama\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मुख्य च्याटमा फर्क\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terug naar hoofdchat\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wróć do głównego czatu\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"voltar ao chat principal\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"voltar ao chat principal\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"назад в основной чат\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tillbaka till huvudchatten\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"முதன்மை உரையாடலுக்கு திரும்பு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"กลับไปยังห้องแชทหลัก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ana sohbete dön\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"назад до основного чату\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مرکزی چیٹ پر واپس جائیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"quay lại phòng chat chính\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"返回主聊天\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"返回主聊天\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.connected_mesh\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"متصل عبر mesh\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"মেশের মাধ্যমে সংযুক্ত\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verbunden über mesh\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"connected via mesh\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"conectado por mesh\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nakakonekta sa pamamagitan ng mesh\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"connecté via mesh\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מחובר דרך mesh\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मेश से जुड़ा\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terhubung lewat mesh\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"connesso tramite mesh\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh経由で接続\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"메시를 통해 연결됨\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terhubung lewat mesh\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh मार्फत जडान\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verbonden via mesh\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"połączono przez mesh\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ligado por mesh\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"conectado por mesh\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"подключено через mesh\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ansluten via mesh\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh மூலம் இணைக்கப்பட்டுள்ளது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เชื่อมต่อผ่านเครือข่าย mesh\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh üzerinden bağlı\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"з'єднано через mesh\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh کے ذریعے جڑا ہوا\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đang kết nối qua mesh\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"通过 mesh 已连接\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"透過 mesh 已連線\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.encryption_status\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"حالة التشفير: %@\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এনক্রিপশনের অবস্থা: %@\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verschlüsselungsstatus: %@\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encryption status: %@\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"estado de cifrado: %@\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"estado ng pag-encrypt: %@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"état du chiffrement : %@\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מצב הצפנה: %@\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्क्रिप्शन स्थिति: %@\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"status enkripsi: %@\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stato crittografia: %@\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暗号状態: %@\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"암호화 상태: %@\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"status enkripsi: %@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सङ्केतको अवस्था: %@\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"versleutelingsstatus: %@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stan szyfrowania: %@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"estado da encriptação: %@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"status da criptografia: %@\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"статус шифрования: %@\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"krypteringsstatus: %@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறியாக்க நிலை: %@\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"สถานะการเข้ารหัส: %@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şifreleme durumu: %@\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"стан шифрування: %@\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"انکرپشن کی حالت: %@\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"trạng thái mã hóa: %@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"加密状态：%@\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"加密狀態：%@\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.location_channels\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"قنوات الموقع\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"অবস্থান চ্যানেল\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kanäle für standorte\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"location channels\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canales de ubicación\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mga channel batay sa lokasyon\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canaux de localisation\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ערוצי מיקום\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लोकेशन चैनल\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kanal lokasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canali posizione\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ロケーションチャンネル\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"위치 채널\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kanal lokasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"स्थान च्यानल\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"locatiekanalen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kanały lokalizacyjne\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canais de localização\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"canais de localização\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"каналы локации\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"platskanaler\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இட சேனல்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ช่องตามตำแหน่ง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"konum kanalları\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"канали локації\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لوکیشن چینلز\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kênh theo vị trí\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置频道\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置頻道\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.location_notes\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ملاحظات الموقع لهذا المكان\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এই স্থানের লোকেশন নোট\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"standortnotizen für diesen ort\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"location notes for this place\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notas de ubicación de este lugar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mga tala para sa lugar na ito\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notes de localisation pour cet endroit\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הערות מיקום למקום הזה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"इस स्थान के लोकेशन नोट\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"catatan lokasi untuk tempat ini\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"note di posizione per questo posto\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"この場所のロケーションノート\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"이 장소의 위치 노트\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"catatan lokasi untuk tempat ini\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"यस ठाउँका स्थान नोटहरू\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"locatienotities voor deze plek\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notatki lokalizacyjne dla tego miejsca\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notas de localização deste lugar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notas de localização deste lugar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заметки для этого места\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"platsanteckningar för den här platsen\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இந்த இடத்திற்கான குறிப்புகள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"บันทึกตำแหน่งสำหรับสถานที่นี้\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bu yer için konum notları\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"замітки про це місце\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"اس جگہ کیلئے لوکیشن نوٹس\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ghi chú vị trí cho nơi này\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"此位置的笔记\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"此位置的筆記\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.open_unread_private_chat\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"فتح دردشة خاصة غير مقروءة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"না পড়া ব্যক্তিগত চ্যাট খুলুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ungelesene privatnachricht öffnen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"open unread private chat\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"abrir chat privado sin leer\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"buksan ang hindi pa nababasang pribadong chat\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ouvrir le chat privé non lu\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"פתח צ'אט פרטי שלא נקרא\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अपठित निजी चैट खोलें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"buka chat pribadi belum dibaca\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"apri chat privata non letta\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未読のプライベートチャットを開く\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"읽지 않은 비공개 대화 열기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"buka chat pribadi belum dibaca\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नपढिएको निजी च्याट खोल\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ongelezen privéchat openen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"otwórz nieprzeczytany czat prywatny\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"abrir chat privado não lido\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"abrir chat privado não lido\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"открыть непрочитанный приватный чат\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"öppna oläst privat chatt\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"படிக்காத தனியுரையாடலைத் திறக்கவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เปิดแชทส่วนตัวที่ยังไม่ได้อ่าน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"okunmamış özel sohbeti aç\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"відкрити непрочитаний приватний чат\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نہ پڑھی گئی نجی چیٹ کھولیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mở chat riêng chưa đọc\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"打开未读私聊\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"打開未讀私聊\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.people_count\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"few\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d أشخاص\"\n                    }\n                  },\n                  \"many\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d شخص\"\n                    }\n                  },\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d شخص\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d شخص\"\n                    }\n                  },\n                  \"two\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d شخصان\"\n                    }\n                  },\n                  \"zero\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d أشخاص\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d person\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d personen\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d person\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d people\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d persona\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d personas\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d personne\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d personnes\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"many\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d אנשים\"\n                    }\n                  },\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d אדם\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d אנשים\"\n                    }\n                  },\n                  \"two\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d אנשים\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d orang\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d orang\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d persona\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d persone\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d人\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d人\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d명\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d व्यक्ति\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d व्यक्तिहरू\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d pessoa\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d pessoas\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"few\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d человека\"\n                    }\n                  },\n                  \"many\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d человек\"\n                    }\n                  },\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d человек\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d человека\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"few\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d людини\"\n                    }\n                  },\n                  \"many\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d людей\"\n                    }\n                  },\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d людина\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d людини\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          },\n          \"substitutions\" : {\n            \"people\" : {\n              \"argNum\" : 1,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d 人\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d 人\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%#@people@\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.private_chat_header\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"دردشة خاصة مع %@\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@-এর সঙ্গে ব্যক্তিগত চ্যাট\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"privatchat mit %@\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"private chat with %@\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chat privado con %@\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pribadong chat kasama si %@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chat privé avec %@\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"צ'אט פרטי עם %@\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ के साथ निजी चैट\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chat pribadi dengan %@\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chat privata con %@\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@とのプライベートチャット\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@과(와) 비공개 대화\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chat pribadi dengan %@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ सँग निजी च्याट\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"privéchat met %@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"czat prywatny z %@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chat privado com %@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chat privado com %@\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"приватный чат с %@\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"privat chatt med %@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ உடன் தனியுரையாடல்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"แชทส่วนตัวกับ %@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ ile özel sohbet\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"приватний чат з %@\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ کے ساتھ نجی چیٹ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chat riêng với %@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"与 %@ 的私聊\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"與 %@ 的私聊\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.reachable_mesh\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"قابل للوصول عبر mesh\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"মেশের মাধ্যমে পৌঁছানো সম্ভব\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"erreichbar über mesh\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"reachable via mesh\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"disponible por mesh\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"maabot sa pamamagitan ng mesh\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"joignable via mesh\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"זמין דרך mesh\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मेश के माध्यम से पहुंच योग्य\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dapat dijangkau lewat mesh\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"raggiungibile via mesh\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"meshで到達可能\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"메시를 통해 연결 가능\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dapat dijangkau lewat mesh\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh मार्फत पहुँचयोग्य\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bereikbaar via mesh\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"osiągalny przez mesh\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"acessível por mesh\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"alcançável por mesh\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"достижим через mesh\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nåbar via mesh\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh மூலம் அணுகலாம்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ติดต่อได้ผ่าน mesh\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh üzerinden ulaşılabilir\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"досяжно через mesh\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh کے ذریعے قابل رسائی\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"liên hệ được qua mesh\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"可通过 mesh 到达\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"可透過 mesh 到達\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.remove_favorite\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إزالة من المفضلة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"প্রিয় থেকে সরান\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aus favoriten entfernen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"remove from favorites\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"quitar de favoritos\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"alisin sa mga paborito\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"retirer des favoris\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הסר מהמועדפים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पसंदीदा से हटाएँ\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hapus dari favorit\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"rimuovi dai preferiti\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"お気に入りから削除\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"즐겨찾기에서 제거\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hapus dari favorit\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मनपर्नेबाट हटाउ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"uit favorieten verwijderen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"usuń z ulubionych\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"remover dos favoritos\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"remover dos favoritos\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"убрать из избранного\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ta bort från favoriter\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பிரியப்பட்டதை நீக்கு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"นำออกจากรายการโปรด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favorilerden kaldır\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"видалити з вибраного\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"پسندیدہ سے ہٹائیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"xóa khỏi yêu thích\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"移出收藏\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"移出收藏\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.send_hint_empty\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"أدخل رسالة للإرسال\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বার্তা পাঠাতে একটি বার্তা লিখুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gib eine nachricht zum senden ein\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enter a message to send\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"introduce un mensaje para enviarlo\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mag-type ng mensahe para ipadala\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"saisis un message à envoyer\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הזן הודעה לשליחה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"संदेश भेजने के लिए संदेश लिखें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"masukkan pesan untuk dikirim\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"inserisci un messaggio da inviare\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"送信するメッセージを入力\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"전송할 메시지를 입력하세요\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"masukkan pesan untuk dihantar\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पठाउन सन्देश लेख\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"voer een bericht in om te sturen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wpisz wiadomość, aby wysłać\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"escreve uma mensagem para enviar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"digite uma mensagem para enviar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"введи сообщение для отправки\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"skriv ett meddelande för att skicka\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அனுப்ப உரையை உள்ளிடவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"พิมพ์ข้อความเพื่อส่ง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bir mesaj göndermek için metin girin\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"введи повідомлення для надсилання\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بھیجنے کیلئے پیغام درج کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nhập tin nhắn để gửi\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"输入要发送的消息\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"輸入要發送的訊息\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.send_hint_ready\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"اضغط مرتين للإرسال\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"পাঠাতে দুইবার ট্যাপ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"doppelt tippen zum senden\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"double tap to send\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toca dos veces para enviar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-double tap para magpadala\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tape deux fois pour envoyer\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הקש פעמיים לשליחה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"भेजने के लिए डबल टैप करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ketuk dua kali untuk mengirim\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tocca due volte per inviare\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ダブルタップで送信\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"전송하려면 두 번 탭하세요\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ketuk dua kali untuk menghantar\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पठाउन दोहोरो ट्याप गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dubbelklikken om te versturen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stuknij dwukrotnie, aby wysłać\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toca duas vezes para enviar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toque duas vezes para enviar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"дважды тапни, чтобы отправить\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dubbeltryck för att skicka\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அனுப்ப இருமுறை தட்டவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"แตะสองครั้งเพื่อส่ง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"göndermek için çift dokunun\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"торкни двічі, щоб надіслати\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بھیجنے کیلئے دو بار ٹیپ کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chạm hai lần để gửi\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"双击发送\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"雙擊發送\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.send_message\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إرسال رسالة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বার্তা পাঠান\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nachricht senden\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"send message\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enviar mensaje\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"magpadala ng mensahe\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"envoyer le message\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"שלח הודעה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"संदेश भेजें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kirim pesan\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"invia messaggio\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"メッセージ送信\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"메시지 보내기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hantar pesan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सन्देश पठाउ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bericht sturen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wyślij wiadomość\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enviar mensagem\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enviar mensagem\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"отправить сообщение\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"skicka meddelande\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"செய்தி அனுப்பு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ส่งข้อความ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesaj gönder\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"надіслати повідомлення\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"پیغام بھیجیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gửi tin nhắn\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"发送消息\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"發送訊息\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.toggle_bookmark\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تبديل الإشارة لـ #%@\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@-এর জন্য বুকমার্ক টগল করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bookmark für #%@ umschalten\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toggle bookmark for #%@\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"alternar marcador para #%@\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-toggle ang bookmark para sa #%@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"basculer le favori pour #%@\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"החלף סימנייה עבור #%@\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ के लिए बुकमार्क टॉगल करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ubah penanda untuk #%@\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cambia segnalibro per #%@\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@のブックマークを切り替え\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ 북마크 토글\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ubah penanda untuk #%@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ का लागि बुकमार्क बदल\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bladwijzer voor #%@ schakelen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"przełącz zakładkę dla #%@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"alternar o marcador de #%@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"alternar favorito para #%@\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"переключить закладку для #%@\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"växla bokmärke för #%@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ க்கான புக்மார்க் மாற்றவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"สลับบุ๊กมาร์กสำหรับ #%@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ için yer işaretini değiştir\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"перемкнути закладку для #%@\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ کیلئے بُک مارک تبدیل کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chuyển đánh dấu cho #%@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"切换 #%@ 的书签\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"切換 #%@ 的書簽\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.toggle_favorite_hint\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"اضغط مرتين لتبديل حالة المفضلة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"প্রিয় অবস্থা বদলাতে দুইবার ট্যাপ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"doppelt tippen, um favoritenstatus zu wechseln\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"double tap to toggle favorite status\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toca dos veces para alternar el estado de favorito\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-double tap para i-toggle ang paborito\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tape deux fois pour basculer le statut favori\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הקש פעמיים כדי להחליף מצב מועדפים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पसंदीदा स्थिति बदलने के लिए डबल टैप करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ketuk dua kali untuk mengubah status favorit\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tocca due volte per cambiare stato preferito\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ダブルタップでお気に入り状態を切り替え\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"두 번 탭하여 즐겨찾기 상태 토글\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ketuk dua kali untuk mengubah status favorit\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मनपर्ने स्थिति बदल्न दोहोरो ट्याप गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dubbelklikken om favoriet te schakelen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stuknij dwukrotnie, aby zmienić stan ulubionych\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toca duas vezes para alternar o estado de favorito\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toque duas vezes para alternar status de favorito\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"дважды тапни, чтобы переключить статус избранного\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dubbeltryck för att växla favoritstatus\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பிரியப்பட்ட நிலையை மாற்ற இருமுறை தட்டவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"แตะสองครั้งเพื่อสลับสถานะรายการโปรด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favori durumunu değiştirmek için çift dokunun\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"торкни двічі, щоб змінити статус вибраного\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"پسندیدہ حالت بدلنے کیلئے دو بار ٹیپ کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chạm hai lần để chuyển trạng thái yêu thích\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"双击切换收藏状态\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"雙擊切換收藏狀態\"\n          }\n        }\n      }\n    },\n    \"content.accessibility.view_fingerprint_hint\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"اضغط لمشاهدة بصمة التشفير\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এনক্রিপশন ফিঙ্গারপ্রিন্ট দেখতে ট্যাপ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tippe, um den verschlüsselungs-fingerprint zu sehen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tap to view encryption fingerprint\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toca para ver la huella de cifrado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-tap para makita ang fingerprint ng pag-encrypt\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tape pour voir l'empreinte de chiffrement\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הקש להצגת טביעת ההצפנה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्क्रिप्शन फिंगरप्रिंट देखने के लिए टैप करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ketuk untuk melihat sidik enkripsi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tocca per vedere l'impronta di cifratura\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暗号フィンガープリントを見る\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"탭하여 암호화 지문 보기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ketuk untuk melihat sidik enkripsi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सङ्केत फिङ्गरप्रिन्ट हेर्न ट्याप गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tik om de versleutelingsfingerprint te bekijken\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stuknij, by zobaczyć odcisk szyfrowania\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toca para ver a impressão de encriptação\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toque para ver a impressão de criptografia\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"нажми, чтобы увидеть криптографический отпечаток\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tryck för att visa krypteringsfingeravtryck\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறியாக்க கைரேகையைப் பார்க்க தட்டவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"แตะเพื่อดูลายนิ้วมือการเข้ารหัส\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şifreleme parmak izini görmek için dokunun\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"торкни, щоб переглянути криптографічний відбиток\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"انکرپشن فنگرپرنٹ دیکھنے کیلئے ٹیپ کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chạm để xem vân tay mã hóa\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"轻点查看加密指纹\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"輕點查看加密指紋\"\n          }\n        }\n      }\n    },\n    \"content.actions.block\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"حظر\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্লক করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blockieren\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"block\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloquear\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-block\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloquer\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"חסום\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ब्लॉक करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blokir\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blocca\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ブロック\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"차단하기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blokir\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ब्लक\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blokkeren\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zablokuj\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloquear\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloquear\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заблокировать\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blockera\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"தடுப்பு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"บล็อก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"engelle\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заблокувати\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بلاک کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chặn\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"屏蔽\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"屏蔽\"\n          }\n        }\n      }\n    },\n    \"content.actions.direct_message\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"رسالة مباشرة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ডিরেক্ট মেসেজ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"direktnachricht\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"direct message\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mensaje directo\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"diretsong mensahe\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"message direct\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הודעה ישירה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"डायरेक्ट संदेश\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pesan langsung\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"messaggio diretto\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ダイレクトメッセージ\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"다이렉트 메시지 보내기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pesan langsung\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रत्यक्ष सन्देश\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"direct bericht\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wiadomość prywatna\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mensagem direta\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mensagem direta\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"личное сообщение\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"direktmeddelande\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"நேரடி செய்தி\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ส่งข้อความส่วนตัว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"doğrudan mesaj\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"приватне повідомлення\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"براہ راست پیغام\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nhắn tin trực tiếp\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"私信\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"私信\"\n          }\n        }\n      }\n    },\n    \"content.actions.hug\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"عناق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"আলিঙ্গন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"umarmen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hug\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"abrazo\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"yakap\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"câlin\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"חיבוק\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"गले लगाएँ\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peluk\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"abbraccia\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ハグ\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"포옹하기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peluk\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अँगालो\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"knuffel\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"przytul\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"abraçar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"abraço\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"обнять\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kram\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அடைகாக்க\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"กอด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sarıl\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"обійняти\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"گلے لگائیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ôm\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"拥抱\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"擁抱\"\n          }\n        }\n      }\n    },\n    \"content.actions.mention\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ذكر\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"মেনশন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"erwähnen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mention\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mencionar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"banggitin\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mentionner\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אזכור\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मेंशन करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sebut\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menziona\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"メンション\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"멘션하기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sebut\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"उल्लेख\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"vermelden\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wspomnij\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mencionar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mencionar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"упомянуть\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nämn\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"மேற்கோள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"กล่าวถึง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bahset\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"згадати\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ذکر کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nhắc tới\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"提及\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"提及\"\n          }\n        }\n      }\n    },\n    \"content.actions.slap\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"صفعة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"চপেটাঘাত\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ohrfeige\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"slap\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bofetada\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sampal\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gifle\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"סטירה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"थप्पड़ मारें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tampar\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"schiaffo\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ビンタ\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"툭 치기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tampar\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"थप्पड\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"meppen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"spoliczkuj\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dar uma chapada\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tapa\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"дать леща\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ge en örfil\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அடி\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ตบ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tokatla\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ляпас\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"چاپڑ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tát\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"拍打\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"拍打\"\n          }\n        }\n      }\n    },\n    \"content.actions.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إجراءات\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"অ্যাকশন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aktionen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"actions\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"acciones\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mga aksyon\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"actions\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"פעולות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"क्रियाएँ\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aksi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"azioni\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"アクション\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"작업\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aksi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"कार्य\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"acties\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"akcje\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ações\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ações\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"действия\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"åtgärder\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"செயல்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"การกระทำ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"eylemler\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"дії\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"کارروائیاں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hành động\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"操作\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"操作\"\n          }\n        }\n      }\n    },\n    \"content.alert.bluetooth_required.off\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth متوقف. فعّل bluetooth في الإعدادات لاستخدام bitchat.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্লুটুথ বন্ধ আছে। bitchat ব্যবহার করতে সেটিংসে ব্লুটুথ চালু করুন।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth ist ausgeschaltet. aktiviere bluetooth in den einstellungen, um bitchat zu verwenden.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth is turned off. please turn on bluetooth in settings to use bitchat.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth está desactivado. Actívalo en Ajustes para usar BitChat.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"naka-off ang bluetooth. pakibuksan sa settings para magamit ang bitchat.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth est désactivé. active le bluetooth dans réglages pour utiliser bitchat.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth כבוי. הפעל bluetooth בהגדרות כדי להשתמש ב-bitchat.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ब्लूटूथ बंद है। bitchat उपयोग करने के लिए सेटिंग्स में ब्लूटूथ चालू करें।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth dimatikan. aktifkan bluetooth di pengaturan untuk memakai bitchat.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth è disattivato. attiva bluetooth nelle impostazioni per usare bitchat.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetoothがオフです。設定でbluetoothをオンにしてbitchatを使ってください。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth가 꺼져 있습니다. bitchat을 사용하려면 설정에서 bluetooth를 켜주세요.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bluetooth dimatikan. Hidupkan Bluetooth dalam Tetapan untuk menggunakan bitchat.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth बन्द छ। bitchat प्रयोग गर्न bluetooth सेटिङमा अन गर।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth staat uit. zet bluetooth aan in instellingen om bitchat te gebruiken.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth jest wyłączony. włącz bluetooth w ustawieniach, aby używać bitchat.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"O Bluetooth está desligado. Ativa o Bluetooth em Definições para usar o bitchat.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth está desligado. ative o bluetooth em ajustes para usar bitchat.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth выключен. включи bluetooth в настройках, чтобы использовать bitchat.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth är avstängt. slå på bluetooth i inställningar för att använda bitchat.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bluetooth அணைக்கப்பட்டுள்ளது. bitchat பயன்படுத்த அமைப்பில் Bluetooth ஐ இயக்கவும்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ปิด Bluetooth อยู่ กรุณาเปิดในตั้งค่าเพื่อใช้ bitchat\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bluetooth kapalı. bitchat'i kullanmak için ayarlardan Bluetooth'u açın.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth вимкнений. увімкни bluetooth у налаштуваннях, щоб користуватися bitchat.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bluetooth بند ہے۔ bitchat استعمال کرنے کیلئے سیٹنگز میں Bluetooth آن کریں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth đang tắt. hãy bật trong cài đặt để dùng bitchat.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth 已关闭。请在设置中开启以使用 bitchat。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth 已關閉。請在設定中開啟以使用 bitchat。\"\n          }\n        }\n      }\n    },\n    \"content.alert.bluetooth_required.permission\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تحتاج bitchat إلى إذن bluetooth للاتصال بالأجهزة القريبة. فعّل الوصول في الإعدادات.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"নিকটবর্তী ডিভাইসের সাথে যুক্ত হতে bitchat-এর ব্লুটুথ অনুমতি দরকার। দয়া করে সেটিংসে ব্লুটুথ অ্যাক্সেস সক্রিয় করুন।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat benötigt bluetooth-berechtigung, um sich mit geräten in der nähe zu verbinden. erlaube den zugriff in den einstellungen.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat needs bluetooth permission to connect with nearby devices. please enable bluetooth access in settings.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitChat necesita permiso de Bluetooth para conectarse con dispositivos cercanos. Habilita el acceso en Ajustes.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kailangan ng bitchat ng pahintulot sa bluetooth upang kumonekta sa mga kalapit na device. pakiactivate sa settings.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat a besoin de l'autorisation bluetooth pour se connecter aux appareils proches. active l'accès dans réglages.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat צריכה הרשאת bluetooth כדי להתחבר למכשירים קרובים. אפשר גישה בהגדרות.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat को पास के उपकरणों से जुड़ने के लिए ब्लूटूथ अनुमति चाहिए। कृपया सेटिंग्स में ब्लूटूथ एक्सेस सक्षम करें।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat memerlukan izin bluetooth untuk terhubung dengan perangkat dekat. aktifkan akses di pengaturan.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat richiede l'autorizzazione bluetooth per collegarsi ai dispositivi vicini. abilita l'accesso nelle impostazioni.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchatは近くのデバイスと接続するためbluetooth権限が必要です。設定でアクセスを有効にしてください。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat은 주변 기기와 연결하기 위해 bluetooth 권한이 필요합니다. 설정에서 bluetooth 접근을 활성화해주세요.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat memerlukan kebenaran Bluetooth untuk disambungkan kepada peranti berhampiran. Benarkan capaian dalam Tetapan.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat लाई नजिकका उपकरणसँग जडान हुन bluetooth अनुमति चाहिन्छ। सेटिङमा पहुँच सक्षम गर।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat heeft bluetooth-toegang nodig om verbinding te maken met apparaten in de buurt. schakel dit in bij instellingen.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat potrzebuje uprawnień bluetooth, aby łączyć się z pobliskimi urządzeniami. włącz dostęp w ustawieniach.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"O bitchat precisa de autorização de Bluetooth para se ligar a dispositivos próximos. Ativa o acesso em Definições.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat precisa de permissão de bluetooth para conectar com dispositivos próximos. habilite o acesso em ajustes.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat нужен доступ к bluetooth, чтобы соединяться с ближайшими устройствами. включи разрешение в настройках.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat behöver bluetooth-behörighet för att ansluta till enheter i närheten. aktivera åtkomst i inställningar.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அருகிலுள்ள சாதனங்களை இணைக்க bitchat க்கு Bluetooth அனுமதி தேவை. அமைப்பில் அனுமதி வழங்கவும்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat ต้องการสิทธิ์ Bluetooth เพื่อเชื่อมต่อกับอุปกรณ์ใกล้เคียง กรุณาเปิดสิทธิ์ในตั้งค่า\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat'in yakın cihazlara bağlanması için Bluetooth izni gerekir. lütfen ayarlardan Bluetooth erişimini etkinleştirin.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat потребує дозволу bluetooth для з'єднання з пристроями поруч. ввімкни доступ у налаштуваннях.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"قریب کے آلات سے جڑنے کیلئے bitchat کو Bluetooth اجازت درکار ہے۔ سیٹنگز میں رسائی فعال کریں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat 需要 bluetooth 权限以连接附近设备。请在设置中启用访问。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bitchat 需要 bluetooth 權限以連線至附近裝置。請在設定中啟用存取權限。\"\n          }\n        }\n      }\n    },\n    \"content.alert.bluetooth_required.settings\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"الإعدادات\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"সেটিংস\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"einstellungen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"settings\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ajustes\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"settings\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"réglages\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הגדרות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सेटिंग्स\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pengaturan\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impostazioni\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"設定\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"설정\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tetapan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सेटिङ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"instellingen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ustawienia\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"definições\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ajustes\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"настройки\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"inställningar\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அமைப்புகள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ตั้งค่า\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ayarlar\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"налаштування\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"سیٹنگز\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cài đặt\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"设置\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"設定\"\n          }\n        }\n      }\n    },\n    \"content.alert.bluetooth_required.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مطلوب bluetooth\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্লুটুথ প্রয়োজন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth erforderlich\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth required\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"se requiere Bluetooth\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kailangan ng bluetooth\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth requis\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"נדרש bluetooth\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ब्लूटूथ आवश्यक\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"butuh bluetooth\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"serve bluetooth\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetoothが必要\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth 필요\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bluetooth diperlukan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth आवश्यक\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth vereist\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wymagany bluetooth\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bluetooth necessário\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth necessário\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth обязателен\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bluetooth krävs\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bluetooth தேவையானது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ต้องใช้ Bluetooth\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bluetooth gerekli\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"потрібен bluetooth\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bluetooth درکار ہے\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cần bluetooth\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"需要 bluetooth\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"需要 bluetooth\"\n          }\n        }\n      }\n    },\n    \"content.alert.bluetooth_required.unsupported\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"هذا الجهاز لا يدعم bluetooth. يحتاج bitchat إلى bluetooth للعمل.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এই ডিভাইস ব্লুটুথ সমর্থন করে না। bitchat চালাতে ব্লুটুথ দরকার।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dieses gerät unterstützt kein bluetooth. bitchat benötigt bluetooth zum funktionieren.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"this device does not support bluetooth. bitchat requires bluetooth to function.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"este dispositivo no admite Bluetooth. BitChat necesita Bluetooth para funcionar.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi sinusuportahan ng device na ito ang bluetooth. kailangan ito ng bitchat para gumana.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cet appareil ne prend pas en charge le bluetooth. bitchat en a besoin pour fonctionner.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"המכשיר הזה לא תומך ב-bluetooth. bitchat זקוקה ל-bluetooth כדי לעבוד.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"यह डिवाइस ब्लूटूथ समर्थित नहीं है। bitchat चलाने के लिए ब्लूटूथ जरूरी है।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"perangkat ini tidak mendukung bluetooth. bitchat memerlukan bluetooth untuk berjalan.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"questo dispositivo non supporta bluetooth. bitchat richiede bluetooth per funzionare.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"このデバイスはbluetoothをサポートしていません。bitchatにはbluetoothが必要です。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"이 기기는 bluetooth를 지원하지 않습니다. bitchat이 작동하려면 bluetooth가 필요합니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Peranti ini tidak menyokong Bluetooth. bitchat memerlukan Bluetooth untuk berfungsi.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"यो उपकरणले bluetooth समर्थन गर्दैन। bitchat चलाउन bluetooth चाहिन्छ।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dit apparaat ondersteunt geen bluetooth. bitchat heeft bluetooth nodig om te werken.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"to urządzenie nie obsługuje bluetooth. bitchat wymaga bluetooth do działania.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Este dispositivo não suporta Bluetooth. O bitchat precisa de Bluetooth para funcionar.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"este dispositivo não suporta bluetooth. bitchat precisa de bluetooth para funcionar.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"это устройство не поддерживает bluetooth. bitchat нужен bluetooth для работы.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"denna enhet stöder inte bluetooth. bitchat behöver bluetooth för att fungera.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இந்த சாதனம் Bluetooth ஐ ஆதரிக்காது. bitchat இயங்க Bluetooth தேவை.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"อุปกรณ์นี้ไม่รองรับ Bluetooth การใช้งาน bitchat ต้องใช้ Bluetooth\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bu cihaz Bluetooth'u desteklemiyor. bitchat'in çalışması için Bluetooth gerekli.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"цей пристрій не підтримує bluetooth. bitchat потрібен bluetooth для роботи.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"یہ ڈیوائس Bluetooth کو سپورٹ نہیں کرتی۔ bitchat کو چلنے کیلئے Bluetooth چاہیے۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"thiết bị này không hỗ trợ bluetooth. bitchat cần bluetooth để hoạt động.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"此设备不支持 bluetooth。bitchat 需要 bluetooth 才能运行。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"此裝置不支持 bluetooth。bitchat 需要 bluetooth 才能運行。\"\n          }\n        }\n      }\n    },\n    \"content.alert.screenshot.message\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لقطات قنوات الموقع تكشف موقعك. فكر قبل المشاركة علناً.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"লোকেশন চ্যানেলের স্ক্রিনশট আপনার অবস্থান প্রকাশ করবে। প্রকাশ করার আগে ভেবে দেখুন।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"screenshots von standortkanälen verraten deinen standort. überleg dir das teilen vorher gut.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"screenshots of location channels will reveal your location. think before sharing publicly.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"las capturas de pantalla de los canales de ubicación revelarán tu ubicación. Piensa antes de compartirlas públicamente.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ibinubunyag ng screenshot ng mga channel ng lokasyon ang iyong lokasyon. mag-isip bago magbahagi sa publiko.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"les captures des canaux de localisation révéleront ta position. réfléchis avant de partager publiquement.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"צילומי מסך של ערוצי מיקום יחשפו את מיקומך. חשב לפני שיתוף פומבי.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लोकेशन चैनलों के स्क्रीनशॉट आपकी लोकेशन प्रकट करेंगे। सार्वजनिक रूप से साझा करने से पहले सोचें।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tangkapan layar kanal lokasi akan mengungkap lokasimu. pikirkan dulu sebelum membagikannya.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gli screenshot dei canali posizione rivelano la tua posizione. pensaci prima di condividerli.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ロケーションチャンネルのスクリーンショットはあなたの場所を明かします。公開前によく考えてください。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"위치 채널의 스크린샷은 사용자의 위치를 노출할 수 있습니다. 스크린샷을 공개적으로 공유할 때 주의해 주세요.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tangkapan layar kanal lokasi akan mengungkap lokasimu. pikirkan dulu sebelum membagikannya.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"स्थान च्यानलको स्क्रिनसटले तिम्रो स्थान खुलाउँछ। सार्वजनिकरूपमा बाँड्नु अघि सोच।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"screenshots van locatiekanalen geven je locatie prijs. denk na voor je publiek deelt.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zrzuty ekranu kanałów lokalizacyjnych ujawnią twoją lokalizację. przemyśl to przed publicznym udostępnieniem.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Capturas de ecrã dos canais de localização revelam a tua localização. Pensa antes de partilhar publicamente.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"capturas de canais de localização revelam sua localização. pense antes de compartilhar publicamente.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"скриншоты каналов местоположения раскроют твою позицию. подумай, прежде чем делиться публично.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"skärmbilder från platskanaler avslöjar din position. tänk efter innan du delar offentligt.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இட சேனல்களின் திரைப் படங்கள் உங்கள் இடத்தை வெளிப்படுத்தும். பொதுவாகப் பகிர்வதற்கு முன் சிந்தியுங்கள்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"การจับภาพหน้าจอช่องตำแหน่งจะเผยตำแหน่งของคุณ โปรดคิดก่อนแชร์สาธารณะ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"konum kanallarının ekran görüntüleri konumunuzu ortaya çıkarır. paylaşmadan önce iki kez düşünün.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"скріншоти каналів локації розкриють твоє місце. подумай, перш ніж ділитися публічно.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لوکیشن چینلز کے اسکرین شاٹس آپ کی لوکیشن ظاہر کر دیں گے۔ عوامی طور پر شیئر کرنے سے پہلے سوچیں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置频道的截图会暴露你的位置。公开分享前请三思。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置頻道的截圖會暴露你的位置。公開分享前請三思。\"\n          }\n        }\n      }\n    },\n    \"content.alert.screenshot.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تنبيه\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"সতর্কতা\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"achtung\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"heads up\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"atención\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"paalala\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"attention\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"שים לב\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ध्यान दें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"perhatian\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"attenzione\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"注意\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"알림\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"perhatian\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ध्यान\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"let op\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"uwaga\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"atenção\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"atenção\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"внимание\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"obs\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"கவனம்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"แจ้งเตือน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dikkat\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"увага\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"خبردار\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lưu ý\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"注意\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"注意\"\n          }\n        }\n      }\n    },\n    \"content.commands.block\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"حظر أو عرض المحظورين\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্লক বা ব্লক করা পিয়ারদের তালিকা দেখুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blocked peers anzeigen oder blockieren\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"block or list blocked peers\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloquear o listar usuarios bloqueados\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-block o tingnan ang listahan ng mga na-block na peer\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloquer ou lister les pairs bloqués\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"חסום או הצג עמיתים חסומים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ब्लॉक करें या ब्लॉक पीयरों की सूची देखें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blokir atau lihat peer yang diblokir\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blocca o mostra i peer bloccati\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ブロックまたはブロック済みを表示\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"피어 차단 또는 차단된 피어 목록 보기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blokir atau lihat peer yang diblokir\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ब्लक गर वा ब्लक गरिएको सूची देखाउ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blokkeer of toon lijst met geblokkeerde peers\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zablokuj albo pokaż listę zablokowanych peerów\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloquear ou listar pares bloqueados\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloquear ou listar pares bloqueados\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заблокировать или показать заблокированных\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blockera eller visa lista över blockerade peers\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"தடை செய்யவும் அல்லது தடை செய்யப்பட்ட peer பட்டியலைப் பாருங்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"บล็อกหรือดูรายชื่อเพียร์ที่ถูกบล็อก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"engelle veya engellenen eşleri listele\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заблокувати або показати заблокованих\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer کو بلاک کریں یا فہرست دیکھیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chặn hoặc xem danh sách nút đã chặn\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"屏蔽或查看已屏蔽的同伴\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"屏蔽或查看已屏蔽的同伴\"\n          }\n        }\n      }\n    },\n    \"content.commands.clear\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مسح رسائل الدردشة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"চ্যাট মেসেজ মুছে দিন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chatnachrichten löschen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"clear chat messages\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"borrar los mensajes del chat\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"burahin ang mga mensahe sa chat\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"effacer les messages du chat\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"נקה הודעות צ'אט\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"चैट संदेश साफ़ करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hapus pesan chat\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"svuota la chat\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"チャットをクリア\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"대화 메시지 지우기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hapus pesan chat\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"च्याट सन्देश खाली गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"maak chatberichten leeg\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wyczyść wiadomości czatu\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"limpar mensagens do chat\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"limpar mensagens do chat\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"очистить чат\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"rensa chattmeddelanden\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"உரையாடல் செய்திகளை அழிக்கவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ล้างข้อความแชท\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sohbet mesajlarını temizle\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"очистити чат\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"چیٹ پیغامات صاف کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"xóa tin nhắn chat\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"清除聊天消息\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"清除聊天訊息\"\n          }\n        }\n      }\n    },\n    \"content.commands.favorite\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إضافة للمفضلة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"প্রিয়তে যোগ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zu favoriten hinzufügen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"add to favorites\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"agregar a favoritos\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"idagdag sa mga paborito\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ajouter aux favoris\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הוסף למועדפים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पसंदीदा में जोड़ें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tambah ke favorit\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aggiungi ai preferiti\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"お気に入りに追加\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"즐겨찾기에 추가하기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tambah ke favorit\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मनपर्नेमा थप\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aan favorieten toevoegen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dodaj do ulubionych\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"adicionar aos favoritos\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"adicionar aos favoritos\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"добавить в избранное\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lägg till i favoriter\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பிரியப்பட்டதில் சேர்க்கவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เพิ่มในรายการโปรด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favorilere ekle\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"додати до вибраного\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"پسندیدہ میں شامل کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"thêm vào yêu thích\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"加入收藏\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"加入收藏\"\n          }\n        }\n      }\n    },\n    \"content.commands.hug\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إرسال عناق دافئ\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"কাউকে উষ্ণ আলিঙ্গন পাঠান\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"eine warme umarmung senden\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"send someone a warm hug\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enviar un abrazo caluroso\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"magpadala ng mainit na yakap\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"envoyer un câlin chaleureux\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"שלח חיבוק חם\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"किसी को गर्म आलिंगन भेजें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kirim pelukan hangat\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"invia un caldo abbraccio\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"あたたかいハグを送る\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"따뜻한 포옹 보내기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hantar pelukan hangat\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"न्यानो अँगालो पठाउ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stuur iemand een warme knuffel\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wyślij komuś ciepły uścisk\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enviar um abraço caloroso\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enviar um abraço quente\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"отправить тёплое объятие\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"skicka en varm kram\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ஒருவருக்கு வெப்பமான அணைப்பை அனுப்பவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ส่งกอดอุ่น ๆ ให้ใครสักคน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"birine sıcak bir sarılma gönder\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"відправити теплі обійми\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"کسی کو گرمجوشی سے گلے لگائیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gửi một cái ôm ấm áp\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"送出温暖拥抱\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"送出溫暖擁抱\"\n          }\n        }\n      }\n    },\n    \"content.commands.message\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إرسال رسالة خاصة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্যক্তিগত বার্তা পাঠান\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"privatnachricht senden\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"send private message\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enviar mensaje privado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"magpadala ng pribadong mensahe\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"envoyer un message privé\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"שלח הודעה פרטית\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"निजी संदेश भेजें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kirim pesan pribadi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"invia messaggio privato\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"プライベートメッセージを送る\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"비공개 메시지 보내기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hantar pesan pribadi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"निजी सन्देश पठाउ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stuur privébericht\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wyślij wiadomość prywatną\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enviar mensagem privada\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enviar mensagem privada\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"отправить приватное сообщение\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"skicka privat meddelande\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"தனியுரையாடல் அனுப்பவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ส่งข้อความส่วนตัว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"özel mesaj gönder\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"надіслати приватне повідомлення\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نجی پیغام بھیجیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gửi tin nhắn riêng\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"发送私信\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"發送私信\"\n          }\n        }\n      }\n    },\n    \"content.commands.slap\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"صفع شخص بسمكة تراوت\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"কারওকে ট্রাউট মাছ দিয়ে চপেটাঘাত করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"jemandem eine forelle um die ohren schlagen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"slap someone with a trout\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"abofetear a alguien con una trucha\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sampalin ang isang tao gamit ang trout\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gifler quelqu'un avec une truite\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"תן למישהו סטירת פורל\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"किसी को ट्राउट से थप्पड़ मारें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tampar seseorang dengan ikan trout\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"schiaffeggia qualcuno con una trota\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"誰かをトラウトでたたく\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"송어로 때리기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tampar seseorang dengan ikan trout\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"कसैलाई ट्राउटले थप्पड दे\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geef iemand een mep met een forel\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"spoliczkuj kogoś trocią\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dar uma chapada a alguém com uma truta\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dar um tapa em alguém com uma truta\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"дать кому-то пощёчину форелью\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"smäll någon med en öring\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ஒருவரை trout மீனால் அடிக்கவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ตบใครสักคนด้วยปลาเทราต์\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"birine alabalıkla tokat at\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"лупнути когось фореллю\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"کسی کو ٹراؤٹ سے تھپڑ ماریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tát ai đó bằng cá hồi\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"用鳟鱼拍某人\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"用鱒魚拍某人\"\n          }\n        }\n      }\n    },\n    \"content.commands.unblock\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إلغاء حظر قرين\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"কাউকে আনব্লক করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer entsperren\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"unblock a peer\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"desbloquear a un usuario\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"alisin ang pag-block sa peer\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"débloquer un pair\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"בטל חסימה לעמית\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"किसी पीयर को अनब्लॉक करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"buka blokir peer\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sblocca un peer\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ピアのブロックを解除\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"피어 차단 해제하기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"buka blokir peer\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पीयर अनब्लक गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer deblokkeren\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"odblokuj peera\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"desbloquear um par\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"desbloquear um par\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"разблокировать пира\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"avblockera peer\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer தடை நீக்கு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เลิกบล็อกเพียร์\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bir eşin engelini kaldır\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"розблокувати піра\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer کا بلاک ہٹائیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bỏ chặn nút\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"取消屏蔽同伴\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"取消屏蔽同伴\"\n          }\n        }\n      }\n    },\n    \"content.commands.unfavorite\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إزالة من المفضلة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"প্রিয় থেকে সরিয়ে দিন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aus favoriten entfernen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"remove from favorites\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"quitar de favoritos\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"alisin sa mga paborito\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"retirer des favoris\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הסר מהמועדפים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पसंदीदा से हटाएँ\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hapus dari favorit\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"rimuovi dai preferiti\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"お気に入りから外す\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"즐겨찾기에서 제거하기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hapus dari favorit\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मनपर्नेबाट हटाउ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"uit favorieten verwijderen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"usuń z ulubionych\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"remover dos favoritos\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"remover dos favoritos\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"убрать из избранного\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ta bort från favoriter\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பிரியப்பட்டதில் இருந்து நீக்கு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"นำออกจากรายการโปรด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"favorilerden çıkar\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"видалити з вибраного\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"پسندیدہ سے ہٹا دیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"xóa khỏi yêu thích\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"移出收藏\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"移出收藏\"\n          }\n        }\n      }\n    },\n    \"content.commands.who\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"عرض من هو متصل\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"কে অনলাইনে আছে দেখুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sehen, wer online ist\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"see who's online\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ver quién está en línea\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tingnan kung sino ang online\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"voir qui est en ligne\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ראה מי מחובר\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"कौन ऑनलाइन है देखें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lihat siapa yang online\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"vedi chi è online\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"オンラインの人を見る\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"온라인인 사람 확인하기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lihat siapa yang online\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अनलाइन को-को छन् हेर्नु\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bekijk wie online is\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zobacz kto jest online\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ver quem está online\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ver quem está online\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"посмотреть, кто онлайн\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"se vem som är online\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"யார் ஆன்லைனில் உள்ளனர்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ดูว่าใครออนไลน์\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kimler çevrimiçi gör\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"подивитися, хто онлайн\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"دیکھیں کون آن لائن ہے\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"xem ai đang online\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"查看谁在线\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"查看誰在線\"\n          }\n        }\n      }\n    },\n    \"content.delivery.delivered_members\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تم التسليم إلى %1$d من %2$d عضو\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%2$d জন সদস্যের মধ্যে %1$d জনের কাছে পৌঁছেছে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zugestellt an %1$d von %2$d mitgliedern\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"delivered to %1$d of %2$d members\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"entregado a %1$d de %2$d miembros\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"naihatid sa %1$d ng %2$d miyembro\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"livré à %1$d sur %2$d membres\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"נמסר ל-%1$d מתוך %2$d חברים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%2$d सदस्यों में से %1$d को पहुँचाया\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terkirim ke %1$d dari %2$d anggota\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"consegnato a %1$d di %2$d membri\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%2$d人中%1$d人に配信\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%2$d명 중 %1$d명에게 전달되었습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terhantar ke %1$d dari %2$d anggota\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%2$d सदस्यमध्ये %1$d जनालाई पुर्याइयो\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bezorgd bij %1$d van %2$d leden\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dostarczono do %1$d z %2$d członków\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"entregue a %1$d de %2$d membros\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"entregue a %1$d de %2$d membros\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"доставлено %1$d из %2$d участников\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"levererat till %1$d av %2$d medlemmar\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%2$d பேரில் %1$d பேருக்கு வழங்கப்பட்டது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ส่งถึง %1$d จาก %2$d คนแล้ว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%2$d üyeden %1$d kişiye ulaştı\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"доставлено %1$d з %2$d учасників\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%2$d میں سے %1$d اراکین کو پہنچا دیا گیا\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đã gửi tới %1$d trong %2$d thành viên\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已送达 %2$d 人中的 %1$d 人\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已送達 %2$d 人中的 %1$d 人\"\n          }\n        }\n      }\n    },\n    \"content.delivery.delivered_to\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"سُلّم إلى %@\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@-এর কাছে পৌঁছেছে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zugestellt an %@\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"delivered to %@\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"entregado a %@\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"naihatid kay %@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"livré à %@\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"נמסר ל-%@\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ को पहुँचाया\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terkirim ke %@\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"consegnato a %@\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@に配信\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@에게 전달되었습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terhantar ke %@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ लाई पुर्याइयो\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bezorgd bij %@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dostarczono do %@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"entregue a %@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"entregue para %@\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"доставлено %@\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"levererat till %@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ க்கு வழங்கப்பட்டது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ส่งถึง %@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@'a iletildi\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"доставлено %@\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ کو پہنچایا گیا\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gửi tới %@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已送达 %@\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已送達 %@\"\n          }\n        }\n      }\n    },\n    \"content.delivery.failed\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"فشل: %@\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্যর্থ: %@\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fehlgeschlagen: %@\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"failed: %@\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"falló: %@\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nabigo: %@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"échec : %@\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"נכשל: %@\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"विफल: %@\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gagal: %@\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"non riuscito: %@\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"失敗: %@\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"실패: %@\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gagal: %@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"असफल: %@\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mislukt: %@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"niepowodzenie: %@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"falhou: %@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"falhou: %@\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ошибка: %@\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"misslyckades: %@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"தோல்வி: %@\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ล้มเหลว: %@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"başarısız: %@\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не вдалося: %@\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ناکام: %@\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"thất bại: %@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"失败：%@\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"失敗：%@\"\n          }\n        }\n      }\n    },\n    \"content.delivery.read_by\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"قُرِئ بواسطة %@\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ পড়েছে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gelesen von %@\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"read by %@\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"leído por %@\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"binasa ni %@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lu par %@\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"נקרא על ידי %@\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ ने पढ़ा\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dibaca oleh %@\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"letto da %@\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@が既読\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@가 읽음\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dibaca oleh %@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ ले पढ्यो\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gelezen door %@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"przeczytane przez %@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lido por %@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lido por %@\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"прочитано %@\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"läst av %@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ படித்தார்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"อ่านโดย %@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ okudu\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"прочитано %@\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ نے پڑھا\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đã đọc bởi %@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已读：%@\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已讀：%@\"\n          }\n        }\n      }\n    },\n    \"content.delivery.reason.blocked\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"المستخدم محظور\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্যবহারকারী ব্লক করা\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nutzer blockiert\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"user is blocked\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"el usuario está bloqueado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"na-block ang user\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"utilisateur bloqué\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"המשתמש חסום\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"उपयोगकर्ता ब्लॉक है\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pengguna diblokir\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"utente bloccato\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ユーザーをブロック中\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"차단된 사용자입니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pengguna diblokir\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रयोगकर्ता ब्लक गरिएको\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gebruiker is geblokkeerd\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"użytkownik zablokowany\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"utilizador bloqueado\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"usuário bloqueado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"пользователь заблокирован\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"användaren är blockerad\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பயனர் தடுக்கப்பட்டுள்ளார்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ผู้ใช้ถูกบล็อก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kullanıcı engellendi\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"користувач заблокований\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"صارف بلاک ہے\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"người dùng bị chặn\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"用户已被屏蔽\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"使用者已被屏蔽\"\n          }\n        }\n      }\n    },\n    \"content.delivery.reason.self\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لا يمكن الإرسال لنفسك\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"নিজেকে বার্তা পাঠানো যায় না\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kann nicht an dich selbst senden\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cannot message yourself\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no puedes enviarte mensajes a ti mismo\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi puwedeng magpadala sa sarili mo\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossible d'envoyer à toi-même\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אי אפשר לשלוח לעצמך\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"खुद को संदेश नहीं भेज सकते\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa kirim ke diri sendiri\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossibile inviarti il messaggio\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"自分には送れません\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"자신에게 메시지를 보낼 수 없습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa hantar ke diri sendiri\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"आफूलाई पठाउन मिल्दैन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"je kunt jezelf geen bericht sturen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nie możesz wysłać wiadomości do siebie\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não é possível enviar mensagem a ti próprio\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não é possível enviar mensagem para si mesmo\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"нельзя отправить себе\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan inte skicka till dig själv\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"உங்களுக்கு நீங்களே அனுப்ப முடியாது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่สามารถส่งข้อความถึงตัวเองได้\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kendine mesaj gönderemezsin\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не можна надіслати собі\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"خود کو پیغام نہیں بھیج سکتے\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không thể tự gửi cho chính mình\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"不能给自己发消息\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"不能給自己發訊息\"\n          }\n        }\n      }\n    },\n    \"content.delivery.reason.send_error\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"خطأ في الإرسال\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"পাঠাতে ত্রুটি\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sende-fehler\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"send error\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"error al enviar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"error sa pagpapadala\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"erreur d'envoi\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"שגיאת שליחה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"भेजने में त्रुटि\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kesalahan pengiriman\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"errore di invio\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"送信エラー\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"전송 오류\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kesalahan penghantaran\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पठाउने त्रुटि\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verzendfout\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"błąd wysyłania\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"erro ao enviar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"erro ao enviar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ошибка отправки\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sändningsfel\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அனுப்பும் பிழை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เกิดข้อผิดพลาดในการส่ง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gönderme hatası\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"помилка надсилання\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بھیجنے میں غلطی\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lỗi gửi\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"发送错误\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"發送錯誤\"\n          }\n        }\n      }\n    },\n    \"content.delivery.reason.unknown_recipient\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مستلم غير معروف\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"অজানা প্রাপক\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"unbekannter empfänger\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"unknown recipient\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"destinatario desconocido\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi kilalang tatanggap\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"destinataire inconnu\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"נמען לא ידוע\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अज्ञात प्राप्तकर्ता\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"penerima tidak dikenal\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"destinatario sconosciuto\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"不明な宛先\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"알 수 없는 수신자입니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"penerima tidak dikenal\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अज्ञात प्राप्तकर्ता\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"onbekende ontvanger\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nieznany odbiorca\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"destinatário desconhecido\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"destinatário desconhecido\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"неизвестный получатель\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"okänd mottagare\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பெறுநர் தெரியவில்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่รู้จักผู้รับ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bilinmeyen alıcı\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"невідомий одержувач\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نامعلوم وصول کنندہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"người nhận không xác định\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未知收件人\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未知收件人\"\n          }\n        }\n      }\n    },\n    \"content.delivery.reason.unreachable\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"القرين غير متاح\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"পিয়ার পৌঁছানো যাচ্ছে না\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer nicht erreichbar\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer not reachable\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"el destinatario no es alcanzable\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi maabot ang peer\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pair injoignable\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"עמית לא זמין\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पीयर पहुंच योग्य नहीं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer tidak dapat dijangkau\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer irraggiungibile\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ピアに到達できません\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"피어에 연결할 수 없습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer tidak dapat dijangkau\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पीयर पहुँचयोग्य छैन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer onbereikbaar\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer nieosiągalny\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"par inacessível\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"par inalcançável\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"пир недостижим\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer ej nåbar\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer அணுக முடியவில்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ติดต่อเพียร์ไม่ได้\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"eşe ulaşılamıyor\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"пір недосяжний\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"peer تک رسائی نہیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không liên lạc được với nút\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"同伴不可达\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"同伴不可達\"\n          }\n        }\n      }\n    },\n    \"content.header.people\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"أشخاص\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"মানুষ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PERSONEN\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PEOPLE\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PERSONAS\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PEOPLE\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PERSONNES\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אנשים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लोग\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ORANG\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PERSONE\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ユーザー\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"참여자\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ORANG\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मानिस\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"MENSEN\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"OSOBY\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PESSOAS\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PESSOAS\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ЛЮДИ\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PERSONER\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PEOPLE\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PEOPLE\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"KİŞİLER\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ЛЮДИ\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PEOPLE\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"PEOPLE\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"成员\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"成員\"\n          }\n        }\n      }\n    },\n    \"content.help.verification\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"التحقق: عرض رمز qr الخاص بي أو مسح صديق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"যাচাই: আমার QR দেখান বা বন্ধুরটি স্ক্যান করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifizierung: meinen qr zeigen oder freund scannen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verification: show my QR or scan a friend\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verificación: mostrar mi QR o escanear a un amigo\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"beripikasyon: ipakita ang aking QR o i-scan ang sa kaibigan\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"vérification : afficher mon qr ou scanner un ami\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אימות: הצג את ה-qr שלי או סרוק חבר\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सत्यापन: मेरा QR दिखाएँ या मित्र का स्कैन करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifikasi: tampilkan qr-ku atau pindai teman\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifica: mostra il mio qr o scansiona un amico\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"検証: 自分のqrを表示するか友達をスキャン\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"인증: 내 QR을 보여주거나 친구의 QR 스캔하기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifikasi: tampilkan qr-ku atau pindai teman\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रमाणीकरण: मेरो qr देखाउ वा साथी स्क्यान गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verificatie: toon mijn QR of scan die van een vriend\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"weryfikacja: pokaż mój QR lub zeskanuj znajomego\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verificação: mostra o meu QR ou lê o de um amigo\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verificação: mostrar meu qr ou escanear um amigo\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"верификация: показать мой qr или сканировать друга\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifiering: visa min QR eller skanna en vän\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சரிபார்ப்பு: எனது QR ஐ காட்டவும் அல்லது நண்பரின் QR ஐ ஸ்கேன் செய்யவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"การยืนยัน: แสดง QR ของฉันหรือสแกนของเพื่อน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"doğrulama: QR kodumu göster veya bir arkadaşını tara\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"верифікація: показати мій qr або сканувати друга\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"توثیق: میرا QR دکھائیں یا دوست کا اسکین کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"xác minh: hiển thị QR của tôi hoặc quét của bạn bè\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"验证：展示我的 qr 或扫描好友\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"驗證：展示我的 qr 或掃描好友\"\n          }\n        }\n      }\n    },\n    \"content.input.message_placeholder\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"اكتب رسالة...\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"একটি বার্তা লিখুন...\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nachricht eingeben...\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"type a message...\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"escribe un mensaje...\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mag-type ng mensahe...\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"écris un message...\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"כתוב הודעה...\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"संदेश लिखें...\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ketik pesan...\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"scrivi un messaggio...\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"メッセージを入力...\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"메시지를 입력하세요...\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ketik pesan...\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सन्देश टाइप गर...\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"typ een bericht...\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wpisz wiadomość...\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"escreve uma mensagem...\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"digite uma mensagem...\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"напиши сообщение...\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"skriv ett meddelande...\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ஒரு செய்தியைத் தட்டச்சு செய்க...\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"พิมพ์ข้อความ...\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bir mesaj yazın...\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"напиши повідомлення...\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"پیغام ٹائپ کریں...\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nhập tin nhắn...\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"输入消息...\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"輸入訊息...\"\n          }\n        }\n      }\n    },\n    \"content.input.nickname_placeholder\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لقب\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ডাকনাম\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nickname\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nickname\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"apodo\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"palayaw\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pseudo\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"כינוי\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"उपनाम\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nama panggilan\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nickname\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ニックネーム\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"닉네임\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nama panggilan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"उपनाम\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bijnaam\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pseudonim\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"apelido\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"apelido\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ник\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"smeknamn\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"புனைப் பெயர்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ชื่อเล่น\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"takma ad\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"нік\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نک نیم\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"biệt danh\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"昵称\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暱稱\"\n          }\n        }\n      }\n    },\n    \"content.location.enable\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تفعيل الموقع\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"লোকেশন চালু করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"standort aktivieren\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enable location\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"activar ubicación\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-on ang lokasyon\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"activer la localisation\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הפעל מיקום\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लोकेशन सक्षम करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aktifkan lokasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"attiva posizione\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置情報を有効化\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"위치 활성화\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aktifkan lokasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"स्थान सक्षम गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"locatie inschakelen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"włącz lokalizację\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ativar localização\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"habilitar localização\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"включить локацию\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aktivera plats\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இடத்தை இயக்கு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เปิดใช้งานตำแหน่ง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"konumu etkinleştir\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"увімкнути локацію\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لوکیشن فعال کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bật vị trí\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"启用位置\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"啟用位置\"\n          }\n        }\n      }\n    },\n    \"content.message.copy\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نسخ الرسالة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বার্তা কপি করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nachricht kopieren\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"copy message\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"copiar mensaje\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kopyahin ang mensahe\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"copier le message\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"העתק הודעה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"संदेश कॉपी करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"salin pesan\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"copia messaggio\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"メッセージをコピー\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"메시지 복사\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"salin pesan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सन्देश प्रतिलिपि गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bericht kopiëren\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kopiuj wiadomość\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"copiar mensagem\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"copiar mensagem\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"копировать сообщение\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kopiera meddelande\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"செய்தியை நகலெடு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"คัดลอกข้อความ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesajı kopyala\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"скопіювати повідомлення\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"پیغام کاپی کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sao chép tin nhắn\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"复制消息\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"複製訊息\"\n          }\n        }\n      }\n    },\n    \"content.message.show_less\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"عرض أقل\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"কম দেখান\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"weniger anzeigen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"show less\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mostrar menos\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ipakita nang kaunti\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"afficher moins\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הצג פחות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"कम दिखाएँ\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tampilkan lebih sedikit\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mostra meno\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"表示を減らす\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"간략히 보기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tampilkan lebih sedikit\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"थोरै देखाउ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"minder weergeven\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pokaż mniej\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mostrar menos\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mostrar menos\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"показать меньше\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"visa mindre\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறைவாக காட்ட\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"แสดงน้อยลง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"daha az göster\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"показати менше\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"کم دکھائیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"thu gọn\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"收起\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"收起\"\n          }\n        }\n      }\n    },\n    \"content.message.show_more\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"عرض المزيد\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"আরও দেখান\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mehr anzeigen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"show more\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mostrar más\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ipakita pa\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"afficher plus\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הצג עוד\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"और दिखाएँ\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tampilkan lebih banyak\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mostra di più\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"さらに表示\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"더 보기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tampilkan lebih banyak\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"थप देखाउ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"meer weergeven\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pokaż więcej\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mostrar mais\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mostrar mais\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"показать больше\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"visa mer\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"மேலும் காட்ட\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"แสดงเพิ่มเติม\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"daha fazla göster\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"показати більше\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مزید دکھائیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mở rộng\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"展开\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"展開\"\n          }\n        }\n      }\n    },\n    \"content.notes.location_unavailable\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"الموقع غير متاح\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"অবস্থান অনুপলব্ধ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"standort nicht verfügbar\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"location unavailable\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ubicación no disponible\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"walang lokasyon\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"localisation indisponible\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"המיקום לא זמין\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लोकेशन उपलब्ध नहीं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lokasi tidak tersedia\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"posizione non disponibile\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置情報を取得できません\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"위치 사용 불가\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lokasi tidak tersedia\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"स्थान उपलब्ध छैन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"locatie niet beschikbaar\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lokalizacja niedostępna\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"localização indisponível\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"localização indisponível\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"локация недоступна\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"plats ej tillgänglig\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இடம் கிடைக்கவில்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่มีข้อมูลตำแหน่ง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"konum kullanılamıyor\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"локація недоступна\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لوکیشن دستیاب نہیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không có dữ liệu vị trí\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置不可用\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置不可用\"\n          }\n        }\n      }\n    },\n    \"content.notes.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ملاحظات\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"নোট\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notizen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notes\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notas\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mga tala\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notes\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הערות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नोट्स\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"catatan\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"note\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ノート\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"노트\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"catatan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नोट\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notities\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notatki\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notas\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notas\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заметки\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"anteckningar\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறிப்புகள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"บันทึก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notlar\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"замітки\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نوٹس\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ghi chú\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"笔记\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"筆記\"\n          }\n        }\n      }\n    },\n    \"content.payment.cashu\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"الدفع عبر cashu\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ক্যাশু দিয়ে পরিশোধ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"per cashu bezahlen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pay via cashu\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pagar con Cashu\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"magbayad gamit ang Cashu\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"payer via cashu\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"תשלום דרך cashu\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"कैशु से भुगतान करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bayar via cashu\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"paga con cashu\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cashuで支払う\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cashu로 결제\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bayar via cashu\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cashu मार्फत तिर्नु\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"betalen met Cashu\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zapłać przez Cashu\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pagar com Cashu\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pagar via cashu\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"оплатить через cashu\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"betala med Cashu\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Cashu மூலம் செலுத்தவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"จ่ายด้วย Cashu\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cashu ile öde\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"оплатити через cashu\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Cashu سے ادائیگی کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"thanh toán bằng Cashu\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"通过 cashu 支付\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"透過 cashu 支付\"\n          }\n        }\n      }\n    },\n    \"content.payment.lightning\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"الدفع عبر lightning\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"লাইটনিং দিয়ে পরিশোধ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"per lightning bezahlen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pay via lightning\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pagar con Lightning\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"magbayad gamit ang Lightning\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"payer via lightning\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"תשלום דרך lightning\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लाइटनिंग से भुगतान करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bayar via lightning\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"paga con lightning\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lightningで支払う\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lightning으로 결제\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bayar via lightning\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lightning मार्फत तिर्नु\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"betalen met Lightning\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zapłać przez Lightning\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pagar com Lightning\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pagar via lightning\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"оплатить через lightning\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"betala med Lightning\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Lightning மூலம் செலுத்தவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"จ่ายด้วย Lightning\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lightning ile öde\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"оплатити через lightning\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Lightning سے ادائیگی کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"thanh toán bằng Lightning\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"通过 lightning 支付\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"透過 lightning 支付\"\n          }\n        }\n      }\n    },\n    \"encryption.accessibility.establishing\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"جار إعداد التشفير\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এনক্রিপশন স্থাপিত হচ্ছে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verschlüsselung wird aufgebaut\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"establishing encryption\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"estableciendo cifrado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nagseset up ng pag-encrypt\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"établissement du chiffrement\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הצפנה בהקמה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्क्रिप्शन स्थापित हो रहा है\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menyiapkan enkripsi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"avvio crittografia\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暗号を確立しています\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"암호화 중\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menyiapkan enkripsi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सङ्केत सेट हुँदै\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"versleuteling wordt opgezet\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"trwa ustanawianie szyfrowania\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"a estabelecer encriptação\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"estabelecendo criptografia\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"устанавливается шифрование\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ställer in kryptering\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறியாக்கம் அமைக்கப்படுகிறது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"กำลังตั้งค่าการเข้ารหัส\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şifreleme kuruluyor\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"встановлюється шифрування\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"انکرپشن قائم کی جا رہی ہے\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đang thiết lập mã hóa\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"正在建立加密\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"正在建立加密\"\n          }\n        }\n      }\n    },\n    \"encryption.accessibility.failed\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"فشل التشفير\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এনক্রিপশন ব্যর্থ হয়েছে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verschlüsselung fehlgeschlagen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encryption failed\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cifrado fallido\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nabigo ang pag-encrypt\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chiffrement échoué\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הצפנה נכשלה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्क्रिप्शन विफल हुआ\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enkripsi gagal\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"crittografia fallita\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暗号に失敗\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"암호화 실패\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enkripsi gagal\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सङ्केत असफल\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"versleuteling mislukt\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"szyfrowanie nie powiodło się\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encriptação falhada\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"falha na criptografia\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"шифрование не удалось\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kryptering misslyckades\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறியாக்கம் தோல்வியடைந்தது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"การเข้ารหัสล้มเหลว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şifreleme başarısız\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"шифрування не вдалося\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"انکرپشن ناکام رہی\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mã hóa thất bại\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"加密失败\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"加密失敗\"\n          }\n        }\n      }\n    },\n    \"encryption.accessibility.not_encrypted\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"غير مشفر\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এনক্রিপ্ট করা নেই\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nicht verschlüsselt\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"not encrypted\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sin cifrar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"walang pag-encrypt\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"non chiffré\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"לא מוצפן\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्क्रिप्ट नहीं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak terenkripsi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"non crittografato\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未暗号\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"암호화되지 않음\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak terenkripsi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सङ्केत छैन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"niet versleuteld\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"brak szyfrowania\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sem encriptação\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não criptografado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не зашифровано\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"inte krypterad\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறியாக்கம் செய்யப்படவில்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่ได้เข้ารหัส\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şifrelenmedi\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не зашифровано\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"غیر انکرپٹڈ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chưa mã hóa\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未加密\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未加密\"\n          }\n        }\n      }\n    },\n    \"encryption.accessibility.secured\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مشفر\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এনক্রিপ্ট করা\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verschlüsselt\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encrypted\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cifrado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"na-encrypt\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chiffré\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מוצפן\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्क्रिप्टेड\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terenkripsi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"crittografato\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暗号化済み\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"암호화됨\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terenkripsi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सङ्केत गरिएको\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"versleuteld\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zaszyfrowane\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encriptado\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"criptografado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"зашифровано\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"krypterad\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறியாக்கம் செய்யப்பட்டது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เข้ารหัสแล้ว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şifreli\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"зашифровано\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"انکرپٹڈ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đã mã hóa\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已加密\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已加密\"\n          }\n        }\n      }\n    },\n    \"encryption.accessibility.verified\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مشفر ومُتحقق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এনক্রিপ্ট ও যাচাইকৃত\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verschlüsselt und verifiziert\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encrypted and verified\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cifrado y verificado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"na-encrypt at beripikado\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chiffré et vérifié\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מוצפן ומאומת\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्क्रिप्टेड और सत्यापित\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terenkripsi dan terverifikasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"crittografato e verificato\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暗号化し検証済み\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"암호화 및 인증됨\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terenkripsi dan terverifikasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सङ्केत र प्रमाणित\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"versleuteld en geverifieerd\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zaszyfrowane i zweryfikowane\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encriptado e verificado\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"criptografado e verificado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"зашифровано и проверено\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"krypterad och verifierad\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறியாக்கமும் சரிபார்ப்பும் முடிந்தது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เข้ารหัสและยืนยันแล้ว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şifreli ve doğrulandı\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"зашифровано та перевірено\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"انکرپٹڈ اور تصدیق شدہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đã mã hóa và xác thực\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已加密并验证\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已加密並驗證\"\n          }\n        }\n      }\n    },\n    \"encryption.status.establishing\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"جار إعداد التشفير...\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এনক্রিপশন স্থাপিত হচ্ছে...\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verschlüsselung wird aufgebaut...\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"establishing encryption...\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"estableciendo cifrado...\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nagseset up ng pag-encrypt...\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mise en place du chiffrement...\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מקימים הצפנה...\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्क्रिप्शन स्थापित हो रहा है...\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menyiapkan enkripsi...\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"avvio della crittografia...\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暗号を確立中...\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"암호화 중...\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menyiapkan enkripsi...\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सङ्केत सेट गर्दै...\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"versleuteling wordt opgezet...\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"trwa ustanawianie szyfrowania...\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"a estabelecer encriptação...\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"estabelecendo criptografia...\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"устанавливаем шифрование...\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ställer in kryptering...\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறியாக்கம் அமைக்கப்படுகிறது...\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"กำลังตั้งค่าการเข้ารหัส...\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şifreleme kuruluyor...\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"встановлюємо шифрування...\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"انکرپشن قائم کی جا رہی ہے...\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đang thiết lập mã hóa...\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"正在建立加密...\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"正在建立加密...\"\n          }\n        }\n      }\n    },\n    \"encryption.status.failed\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"فشل التشفير\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এনক্রিপশন ব্যর্থ হয়েছে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verschlüsselung fehlgeschlagen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encryption failed\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cifrado fallido\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nabigo ang pag-encrypt\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chiffrement échoué\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הצפנה נכשלה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्क्रिप्शन विफल हुआ\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enkripsi gagal\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"crittografia fallita\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暗号に失敗\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"암호화 실패\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enkripsi gagal\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सङ्केत असफल\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"versleuteling mislukt\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"szyfrowanie nie powiodło się\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encriptação falhada\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"falha na criptografia\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"шифрование не удалось\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kryptering misslyckades\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறியாக்கம் தோல்வியடைந்தது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"การเข้ารหัสล้มเหลว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şifreleme başarısız\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"шифрування не вдалося\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"انکرپشن ناکام رہی\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mã hóa thất bại\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"加密失败\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"加密失敗\"\n          }\n        }\n      }\n    },\n    \"encryption.status.not_encrypted\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"غير مشفر\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এনক্রিপ্ট করা নেই\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nicht verschlüsselt\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"not encrypted\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sin cifrar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"walang pag-encrypt\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"non chiffré\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"לא מוצפן\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्क्रिप्ट नहीं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak terenkripsi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"non crittografato\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未暗号\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"암호화되지 않음\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak terenkripsi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सङ्केत छैन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"niet versleuteld\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"brak szyfrowania\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sem encriptação\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não criptografado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не зашифровано\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"inte krypterad\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறியாக்கம் இல்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่ได้เข้ารหัส\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şifrelenmedi\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не зашифровано\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"غیر انکرپٹڈ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chưa mã hóa\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未加密\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未加密\"\n          }\n        }\n      }\n    },\n    \"encryption.status.secured\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مشفر\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এনক্রিপ্ট করা\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verschlüsselt\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encrypted\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cifrado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"na-encrypt\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chiffré\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מוצפן\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्क्रिप्टेड\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terenkripsi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"crittografato\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暗号化済み\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"암호화됨\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terenkripsi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सङ्केत गरिएको\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"versleuteld\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zaszyfrowane\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encriptado\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"criptografado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"зашифровано\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"krypterad\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறியாக்கப்பட்டது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เข้ารหัสแล้ว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şifreli\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"зашифровано\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"انکرپٹڈ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đã mã hóa\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已加密\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已加密\"\n          }\n        }\n      }\n    },\n    \"encryption.status.verified\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مشفر ومُتحقق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এনক্রিপ্ট করা ও যাচাইকৃত\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verschlüsselt und verifiziert\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encrypted & verified\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cifrado y verificado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"na-encrypt at beripikado\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chiffré et vérifié\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מוצפן ומאומת\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एन्क्रिप्टेड और सत्यापित\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terenkripsi dan terverifikasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"crittografato e verificato\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暗号化し検証済み\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"암호화 및 인증됨\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"terenkripsi dan terverifikasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सङ्केत र प्रमाणित\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"versleuteld & geverifieerd\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zaszyfrowane i zweryfikowane\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encriptado e verificado\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"criptografado e verificado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"зашифровано и проверено\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"krypterad & verifierad\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறியாக்கப்பட்ட & சரிபார்க்கப்பட்டது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เข้ารหัสและยืนยันแล้ว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şifreli ve doğrulandı\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"зашифровано та перевірено\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"انکرپٹڈ اور تصدیق شدہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đã mã hóa & xác thực\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已加密并验证\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已加密並驗證\"\n          }\n        }\n      }\n    },\n    \"fingerprint.action.mark_verified\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"وضع علامة تم التحقق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"যাচাইকৃত হিসেবে চিহ্নিত করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"als verifiziert markieren\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mark as verified\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"marcar como verificado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"markahan bilang beripikado\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"marquer comme vérifié\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"סמן כמאומת\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सत्यापित के रूप में चिह्नित करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tandai sebagai terverifikasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"segna come verificato\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"検証済みにする\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"인증됨으로 표시\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tandai sebagai terverifikasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रमाणित चिन्ह लगाउ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"markeer als geverifieerd\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"oznacz jako zweryfikowane\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"marcar como verificado\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"marcar como verificado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"пометить как проверено\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"markera som verifierad\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சரிபார்க்கப்பட்டது என குறிக்க\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ทำเครื่องหมายว่ายืนยันแล้ว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"doğrulandı olarak işaretle\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"позначити як перевірено\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تصدیق شدہ کے طور پر نشان زد کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đánh dấu đã xác thực\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"标记为已验证\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"標記為已驗證\"\n          }\n        }\n      }\n    },\n    \"fingerprint.action.remove_verification\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إزالة التحقق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"যাচাইকরণ সরান\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifizierung entfernen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"remove verification\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"eliminar verificación\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"alisin ang beripikasyon\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"retirer la vérification\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הסר אימות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सत्यापन हटाएँ\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hapus verifikasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"rimuovi verifica\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"検証を削除\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"인증 제거\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hapus verifikasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रमाणीकरण हटाउ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verificatie verwijderen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"usuń weryfikację\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"remover verificação\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"remover verificação\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"удалить проверку\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ta bort verifiering\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சரிபார்ப்பை நீக்கு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ลบการยืนยัน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"doğrulamayı kaldır\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"зняти перевірку\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تصدیق ہٹائیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"xóa xác thực\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"移除验证\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"移除驗證\"\n          }\n        }\n      }\n    },\n    \"fingerprint.badge.not_verified\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ غير مُتحقق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ যাচাইকৃত নয়\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ NICHT VERIFIZIERT\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ NOT VERIFIED\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ NO VERIFICADO\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ HINDI BERIPIKADO\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ NON VÉRIFIÉ\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ לא מאומת\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ सत्यापित नहीं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ BELUM TERVERIFIKASI\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ NON VERIFICATO\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ 未検証\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ 인증되지 않음\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ BELUM TERVERIFIKASI\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ प्रमाणित छैन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ NIET GEVERIFICEERD\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ NIEZWERYFIKOWANE\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ NÃO VERIFICADO\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ NÃO VERIFICADO\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ НЕ ПРОВЕРЕНО\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ INTE VERIFIERAD\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ சரிபார்க்கப்படவில்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ ยังไม่ยืนยัน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ DOĞRULANMADI\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ НЕ ПЕРЕВІРЕНО\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ تصدیق نہیں ہوئی\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ CHƯA XÁC THỰC\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ 未验证\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"⚠️ 未驗證\"\n          }\n        }\n      }\n    },\n    \"fingerprint.badge.verified\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ مُتحقق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ যাচাইকৃত\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ VERIFIZIERT\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ VERIFIED\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ VERIFICADO\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ BERIPIKADO\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ VÉRIFIÉ\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ מאומת\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ सत्यापित\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ TERVERIFIKASI\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ VERIFICATO\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ 検証済み\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ 인증됨\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ TERVERIFIKASI\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ प्रमाणित\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ GEVERIFICEERD\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ ZWERYFIKOWANE\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ VERIFICADO\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ VERIFICADO\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ ПРОВЕРЕНО\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ VERIFIERAD\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ சரிபார்க்கப்பட்டது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ ยืนยันแล้ว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ DOĞRULANDI\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ ПЕРЕВІРЕНО\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ تصدیق شدہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ ĐÃ XÁC THỰC\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ 已验证\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"✓ 已驗證\"\n          }\n        }\n      }\n    },\n    \"fingerprint.handshake_pending\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"غير متاح - جار تنفيذ handshake\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"উপলব্ধ নয় - হ্যান্ডশেক চলছে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nicht verfügbar – handshake läuft\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"not available - handshake in progress\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no disponible: el handshake está en curso\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi pa magagamit - nagpapatuloy ang handshake\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"indisponible - handshake en cours\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"לא זמין - handshake מתבצע\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"उपलब्ध नहीं - हैंडशेक जारी है\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak tersedia - handshake sedang berlangsung\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"non disponibile - handshake in corso\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"利用不可 - handshake進行中\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"사용 불가 - 핸드셰이크 진행 중\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak tersedia - handshake sedang berlangsung\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"उपलब्ध छैन - handshake हुँदै\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"niet beschikbaar - handshake bezig\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"niedostępne – handshake w toku\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"indisponível - aperto de mão em curso\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"indisponível - handshake em andamento\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"недоступно — handshake выполняется\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"inte tillgänglig – handskakning pågår\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"கிடைக்கவில்லை - கைச்சாத்து நடந்து கொண்டிருக்கிறது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ยังไม่พร้อม - กำลังจับมือ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kullanılamıyor - el sıkışma sürüyor\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"недоступно — handshake триває\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"دستیاب نہیں - ہینڈ شیک جاری ہے\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chưa sẵn sàng - đang bắt tay\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暂不可用 - handshake 进行中\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"暫不可用 - handshake 進行中\"\n          }\n        }\n      }\n    },\n    \"fingerprint.message.verified\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لقد تحققت من هوية هذا الشخص.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"আপনি এই ব্যক্তির পরিচয় যাচাই করেছেন।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"du hast die identität dieser person verifiziert.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"you have verified this person's identity.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"has verificado la identidad de esta persona.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"na-beripika mo na ang pagkakakilanlan ng taong ito.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tu as vérifié l'identité de cette personne.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אישרת את זהותו של האדם הזה.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"आपने इस व्यक्ति की पहचान सत्यापित की है।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kamu sudah memverifikasi identitas orang ini.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hai verificato l'identità di questa persona.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"この人の身元を確認しました。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"이 사람의 신원을 인증했습니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kamu sudah memverifikasi identitas orang ini.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"तिमीले यस व्यक्तिको पहिचान प्रमाणित गरेको छौ.\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"je hebt de identiteit van deze persoon geverifieerd.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zweryfikowałeś tożsamość tej osoby.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"confirmaste a identidade desta pessoa.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"você verificou a identidade dessa pessoa.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ты подтвердил личность этого человека.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"du har verifierat den här personens identitet.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இந்த நபரின் அடையாளத்தை நீங்கள் உறுதிப்படுத்தியுள்ளீர்கள்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"คุณยืนยันตัวตนของคนนี้แล้ว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bu kişinin kimliğini doğruladınız.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ти підтвердив особу цієї людини.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"آپ نے اس شخص کی شناخت کی تصدیق کی ہے۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bạn đã xác thực danh tính người này.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"你已经核实了此人的身份。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"你已經覈實了此人的身份。\"\n          }\n        }\n      }\n    },\n    \"fingerprint.message.verify_hint\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"قارن هذه البصمات مع %@ عبر قناة آمنة.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"নিরাপদ চ্যানেলে %@-এর সঙ্গে এই ফিঙ্গারপ্রিন্ট মিলিয়ে দেখুন।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"vergleiche diese fingerabdrücke mit %@ über einen sicheren kanal.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"compare these fingerprints with %@ using a secure channel.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"compara estas huellas con %@ mediante un canal seguro.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ikumpara ang mga fingerprint na ito kay %@ sa isang ligtas na channel.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"compare ces empreintes avec %@ via un canal sécurisé.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"השווה את הטביעות עם %@ בערוץ מאובטח.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"इन फिंगरप्रिंट्स की तुलना %@ से सुरक्षित चैनल पर करें।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bandingkan sidik ini dengan %@ lewat kanal aman.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"confronta queste impronte con %@ tramite un canale sicuro.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"これらのフィンガープリントを%@と安全なチャネルで比較\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"보안 채널을 통해 이 지문을 %@와 비교하세요.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bandingkan sidik ini dengan %@ lewat kanal aman.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"यी फिङ्गरप्रिन्टहरू %@ सँग सुरक्षित च्यानलमा तुलना गर।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"vergelijk deze fingerprints met %@ via een veilig kanaal.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"porównaj te odciski z %@ na bezpiecznym kanale.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"compara estas impressões com %@ num canal seguro.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"compare essas impressões com %@ usando um canal seguro.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"сравни эти отпечатки с %@ по безопасному каналу.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"jämför dessa fingeravtryck med %@ via en säker kanal.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இந்த கைரேகைகளை %@ உடன் பாதுகாப்பான வழியில் ஒப்பிடவும்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เปรียบเทียบลายนิ้วมือเหล่านี้กับ %@ ผ่านช่องทางที่ปลอดภัย\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bu parmak izlerini %@ ile güvenli bir kanalda karşılaştırın.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"порівняй ці відбитки з %@ у безпечному каналі.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ان فنگر پرنٹس کو %@ کے ساتھ محفوظ چینل پر ملائیں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"so sánh các vân tay này với %@ qua kênh an toàn.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"通过安全渠道与 %@ 比对这些指纹。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"透過安全渠道與 %@ 比對這些指紋。\"\n          }\n        }\n      }\n    },\n    \"fingerprint.their_label\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بصمتهم:\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"তাদের ফিঙ্গারপ্রিন্ট:\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"deren fingerabdruck:\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"their fingerprint:\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"huella de la otra persona:\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fingerprint nila:\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"leur empreinte :\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הטבעת שלהם:\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"उनका फिंगरप्रिंट:\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidik mereka:\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impronta loro:\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"相手のフィンガープリント:\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"상대방의 지문:\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidik mereka:\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"उनको फिङ्गरप्रिन्ट:\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hun fingerprint:\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ich odcisk:\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impressão digital desta pessoa:\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impressão digital deles:\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"их отпечаток:\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"deras fingeravtryck:\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அவர்களின் கைரேகம்:\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ลายนิ้วมือของอีกฝ่าย:\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"onların parmak izi:\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"їхній відбиток:\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ان کا فنگر پرنٹ:\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"vân tay của họ:\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"对方指纹：\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"對方指紋：\"\n          }\n        }\n      }\n    },\n    \"fingerprint.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تحقق الأمان\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"নিরাপত্তা যাচাই\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sicherheitsverifizierung\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"security verification\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verificación de seguridad\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"beripikasyong panseguridad\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"vérification de sécurité\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אימות אבטחה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सुरक्षा सत्यापन\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifikasi keamanan\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifica di sicurezza\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"セキュリティ検証\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"보안 인증\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifikasi keamanan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सुरक्षा प्रमाणीकरण\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"beveiligingsverificatie\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"weryfikacja bezpieczeństwa\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verificação de segurança\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verificação de segurança\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"проверка безопасности\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"säkerhetsverifiering\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பாதுகாப்பு சரிபார்ப்பு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"การยืนยันความปลอดภัย\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"güvenlik doğrulaması\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"перевірка безпеки\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"سیکیورٹی تصدیق\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"xác minh bảo mật\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"安全验证\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"安全驗證\"\n          }\n        }\n      }\n    },\n    \"fingerprint.your_label\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بصمتك:\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"আপনার ফিঙ্গারপ্রিন্ট:\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dein fingerabdruck:\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"your fingerprint:\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tu huella:\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fingerprint mo:\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ton empreinte :\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הטבעת שלך:\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"आपका फिंगरप्रिंट:\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidikmu:\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tua impronta:\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"あなたのフィンガープリント:\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"나의 지문:\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sidikmu:\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"तिम्रो फिङ्गरप्रिन्ट:\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"jouw fingerprint:\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"twój odcisk:\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"a tua impressão digital:\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sua impressão digital:\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"твой отпечаток:\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ditt fingeravtryck:\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"உங்கள் கைரேகம்:\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ลายนิ้วมือของคุณ:\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sizin parmak iziniz:\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"твій відбиток:\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"آپ کا فنگر پرنٹ:\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"vân tay của bạn:\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"你的指纹：\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"你的指紋：\"\n          }\n        }\n      }\n    },\n    \"geohash_people.action.block\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"حظر\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্লক করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blockieren\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"block\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloquear\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-block\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloquer\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"חסום\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ब्लॉक करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blokir\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blocca\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ブロック\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"차단하기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blokir\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ब्लक\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blokkeren\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zablokuj\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloquear\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloquear\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заблокировать\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blockera\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"தடை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"บล็อก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"engelle\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заблокувати\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بلاک کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chặn\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"屏蔽\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"屏蔽\"\n          }\n        }\n      }\n    },\n    \"geohash_people.action.unblock\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إلغاء الحظر\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"আনব্লক করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"entsperren\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"unblock\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"desbloquear\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-unblock\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"débloquer\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"בטל חסימה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अनब्लॉक करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"buka blokir\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sblocca\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ブロック解除\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"차단 해제하기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"buka blokir\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अनब्लक\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"deblokkeren\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"odblokuj\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"desbloquear\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"desbloquear\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"разблокировать\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"avblockera\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"தடை நீக்கு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เลิกบล็อก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"engeli kaldır\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"розблокувати\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بلاک ہٹائیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bỏ chặn\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"取消屏蔽\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"取消屏蔽\"\n          }\n        }\n      }\n    },\n    \"geohash_people.none_nearby\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لا أحد قريب...\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"কাছে কেউ নেই...\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"niemand in der nähe...\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nobody around...\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nadie cerca...\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"walang tao sa paligid...\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"personne à proximité...\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אין אף אחד בסביבה...\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"आसपास कोई नहीं...\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak ada siapa pun...\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nessuno nei dintorni...\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"近くに誰もいません...\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"주변에 아무도 없습니다...\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak ada siapa pun...\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"वरिपरि कोही छैन...\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"niemand in de buurt...\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nikogo w pobliżu...\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ninguém por perto...\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ninguém por perto...\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"никого рядом...\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ingen i närheten...\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அருகில் யாரும் இல்லை...\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่มีใครอยู่ใกล้...\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"yakında kimse yok...\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"поруч нікого...\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"قریب کوئی نہیں...\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không có ai gần...\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"附近没人...\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"附近沒人...\"\n          }\n        }\n      }\n    },\n    \"geohash_people.tooltip.blocked\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"محظور في geohash\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"জিওহ্যাশে ব্লক করা\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"in geohash blockiert\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blocked in geohash\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloqueado en geohash\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"na-block sa geohash\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloqué dans geohash\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"חסום ב-geohash\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"जियोहैश में ब्लॉक\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"diblokir di geohash\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloccato su geohash\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohashでブロック中\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash에서 차단됨\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"diblokir di geohash\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash मा ब्लक\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geblokkeerd in geohash\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zablokowany w geohash\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloqueado em geohash\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloqueado em geohash\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заблокирован в geohash\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blockerad i geohash\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash இல் தடுக்கப்பட்டது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ถูกบล็อกใน geohash\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash'te engellendi\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заблоковано в geohash\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash میں بلاک شدہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đã chặn trong geohash\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"在 geohash 中已屏蔽\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"在 geohash 中已屏蔽\"\n          }\n        }\n      }\n    },\n    \"geohash_people.you_suffix\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (أنت)\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (আপনি)\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (du)\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (you)\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (tú)\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (ikaw)\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (toi)\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (אתה)\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (आप)\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (kamu)\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (tu)\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (あなた)\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (나)\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (kamu)\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (तिमी)\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (jij)\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (ty)\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (tu)\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (você)\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (ты)\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (du)\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (நீங்கள்)\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (คุณ)\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (sen)\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (ти)\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (آپ)\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (bạn)\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (你)\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \" (你)\"\n          }\n        }\n      }\n    },\n    \"location_channels.action.open_settings\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"فتح الإعدادات\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"সেটিংস খুলুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"einstellungen öffnen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"open settings\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"abrir ajustes\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"buksan ang settings\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ouvrir réglages\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"פתח הגדרות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सेटिंग्स खोलें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"buka pengaturan\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"apri impostazioni\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"設定を開く\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"설정 열기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"buka Tetapan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सेटिङ खोल\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"open instellingen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"otwórz ustawienia\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"abrir definições\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"abrir ajustes\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"открыть настройки\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"öppna inställningar\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அமைப்புகளைத் திறக்க\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เปิดการตั้งค่า\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ayarları aç\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"відкрити налаштування\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"سیٹنگز کھولیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mở cài đặt\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"打开设置\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"打開設定\"\n          }\n        }\n      }\n    },\n    \"location_channels.action.remove_access\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إزالة صلاحية الموقع\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"লোকেশন অ্যাক্সেস সরান\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"standortzugriff entfernen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"remove location access\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"eliminar acceso a la ubicación\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"alisin ang access sa lokasyon\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"retirer l'accès localisation\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הסר גישת מיקום\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लोकेशन एक्सेस हटाएँ\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cabut akses lokasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"revoca accesso alla posizione\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置アクセスを解除\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"위치 접근 권한 제거\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cabut capaian lokasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"स्थान पहुँच हटाउ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"toegang tot locatie verwijderen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"usuń dostęp do lokalizacji\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"remover acesso à localização\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"remover acesso à localização\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"отключить доступ к локации\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ta bort platsåtkomst\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இட அணுகலை அகற்று\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"นำสิทธิ์เข้าถึงตำแหน่งออก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"konum erişimini kaldır\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"відключити доступ до локації\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لوکیشن رسائی ہٹائیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gỡ quyền truy cập vị trí\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"移除位置访问\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"移除位置存取權\"\n          }\n        }\n      }\n    },\n    \"location_channels.action.request_permissions\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"جلب موقعي و geohash\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"লোকেশন অনুমতি ও আমার জিওহ্যাশ নিন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"standort und geohash abrufen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"get location and my geohashes\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"obtener mi ubicación y mis geohashes\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kumuha ng access sa lokasyon at mga geohash ko\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"obtenir ma localisation et mes geohash\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"קבל את המיקום וה-geohash שלי\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लोकेशन अनुमति और मेरे जियोहैश प्राप्त करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ambil lokasiku dan geohash\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ottieni la mia posizione e i geohash\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置情報とgeohashを取得\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"내 위치 및 geohashes 가져오기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ambil lokasiku dan geohash\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मेरो स्थान र geohash प्राप्त गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"vraag locatierechten en mijn geohashes op\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"uzyskaj uprawnienia lokalizacji i moje geohashe\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"obter permissões de localização e os meus geohash\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"obter localização e meus geohashes\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"получить мою локацию и geohash\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hämta platsbehörighet och mina geohashar\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இட அனுமதி மற்றும் என் geohash களைப் பெற\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ขอสิทธิ์ตำแหน่งและ geohash ของฉัน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"konum izni ve geohash'lerimi al\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"отримати мою локацію та geohash\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لوکیشن کی اجازت اور میرے geohash حاصل کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"yêu cầu quyền vị trí và geohash của tôi\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"获取位置和我的 geohash\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"獲取位置和我的 geohash\"\n          }\n        }\n      }\n    },\n    \"location_channels.action.teleport\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"انتقال فوري\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"টেলিপোর্ট\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"teleportieren\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"teleport\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"teletransportar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"teleport\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"téléporter\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"טלפורט\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"टेलीपोर्ट\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"teleport\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"teletrasporto\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"テレポート\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"텔레포트\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"teleport\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"टेलिपोर्ट\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"teleporteer\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"teleportuj\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"teletransportar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"teletransportar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"телепорт\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"teleportera\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"டெலிபோர்ட்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เทเลพอร์ต\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ışınlan\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"телепорт\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ٹیلی پورٹ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dịch chuyển\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"瞬移\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"瞬移\"\n          }\n        }\n      }\n    },\n    \"location_channels.bookmarked_section_title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"محفوظ\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বুকমার্ক করা\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gespeichert\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bookmarked\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"marcados\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"naka-bookmark\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enregistrés\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"שמורים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"बुकमार्क की गई\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"disimpan\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"salvati\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"保存済み\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"북마크\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"disimpan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"बुकमार्क\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bladwijzers\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zapisane\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"marcados\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"marcados\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"закреплённые\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bokmärken\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"புக் மார்க் செய்யப்பட்டவை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ที่คั่นไว้\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"yer imleri\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"закладені\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بک مارک شدہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đã đánh dấu\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已收藏\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已收藏\"\n          }\n        }\n      }\n    },\n    \"location_channels.description\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تحدث مع أشخاص قريبين عبر قنوات geohash. نشارك geohash تقريبي فقط، وليس gps الدقيق. يتم إخفاء عنوان ip لأن كل المرور يمر عبر tor.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"জিওহ্যাশ চ্যানেল দিয়ে কাছাকাছি অঞ্চলের মানুষের সঙ্গে চ্যাট করুন। শুধুই স্থূল জিওহ্যাশ শেয়ার হয়, কখনো সঠিক GPS নয়। আপনার IP টর দিয়ে সব ট্রাফিক রাউট করে লুকানো থাকে।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"שוחח עם אנשים קרובים בערוצי geohash. משתף רק geohash גס, אף פעם לא gps מדויק. כתובת ה-ip מוסתרת כי כל התעבורה עוברת דרך tor.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"जियोहैश चैनलों से आसपास के लोगों से चैट करें। केवल मोटा जियोहैश साझा होता है, कभी सटीक GPS नहीं। आपका IP सारा ट्रैफ़िक Tor से रूट कर छिपा रहता है।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohashチャンネルで近くの人と会話。共有されるのはざっくりしたgeohashだけで正確なgpsは含みません。全トラフィックをtor経由にすることであなたのipを隠します。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash 채널을 사용해 주변 사람들과 대화하세요. 정확한 GPS가 아닌 대략적인 geohash만 공유됩니다. 모든 트래픽을 tor로 라우팅하여 IP 주소를 숨깁니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash च्यानलबाट नजिकका मानिससँग कुरा गर। केवल मोटामो geohash साझा हुन्छ, कहिल्यै सहि gps होइन। सबै ट्राफिक tor मार्फत गएका कारण तिम्रो ip लुकेको हुन्छ।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"общайся с людьми рядом через каналы geohash. делится только грубый geohash, без точного gps. твой ip скрывается за счёт маршрутизации трафика через tor.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash சேனல்கள் மூலம் அருகிலுள்ளவர்களுடன் உரையாடுங்கள். மிகவும் பொது geohash மட்டுமே பகிரப்படும், துல்லியமான GPS அல்ல. உங்கள் IP, அனைத்து போக்குவரத்தையும் tor வழியாக மாற்றுவதன் மூலம் மறைக்கப்படுகிறது.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"แชทกับคนใกล้คุณผ่านช่อง geohash แบ่งปันเพียง geohash แบบหยาบ ไม่ใช่ GPS ที่แม่นยำ ที่อยู่ IP ของคุณถูกซ่อนด้วยการส่งข้อมูลผ่าน tor\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"спілкуйся з людьми поруч у каналах geohash. передається лише грубий geohash, без точного gps. твій ip приховується, бо весь трафік йде через tor.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"قریب کے لوگوں سے گفتگو کیلئے geohash چینلز استعمال کریں۔ صرف عمومی geohash شیئر کیا جاتا ہے، کبھی درست GPS نہیں۔ آپ کا IP tor کے ذریعے ساری ٹریفک روٹ کر کے چھپایا جاتا ہے۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"使用 geohash 频道与附近的人聊天。只会共享粗略 geohash，从不泄露精确 GPS。所有流量通过 tor 路由来隐藏你的 IP。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"使用 geohash 頻道與附近的人聊天。只會共享粗略 geohash，從不洩露精確 GPS。所有流量透過 tor 路由來隱藏你的 IP。\"\n          }\n        }\n      }\n    },\n    \"location_channels.error.invalid_geohash\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash غير صالح\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"অবৈধ জিওহ্যাশ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ungültiger geohash\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"invalid geohash\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash no válido\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"di-wastong geohash\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash invalide\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash לא תקף\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अमान्य जियोहैश\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash tidak valid\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash non valido\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"無効なgeohash\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"잘못된 geohash\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash tidak valid\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अवैध geohash\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ongeldige geohash\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nieprawidłowy geohash\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash inválido\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash inválido\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"некорректный geohash\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ogiltig geohash\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"தவறான geohash\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash ไม่ถูกต้อง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geçersiz geohash\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"некоректний geohash\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"غلط geohash\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash không hợp lệ\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"无效的 geohash\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"無效的 geohash\"\n          }\n        }\n      }\n    },\n    \"location_channels.loading_nearby\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"جار البحث عن قنوات قريبة…\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"কাছাকাছি চ্যানেল খোঁজা হচ্ছে…\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"suche nach kanälen in der nähe…\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"finding nearby channels…\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"buscando canales cercanos…\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"naghahanap ng mga kalapit na channel…\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"recherche de canaux proches…\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מחפש ערוצים קרובים…\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पास के चैनल खोजे जा रहे हैं…\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mencari kanal sekitar…\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ricerca canali vicini…\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"近くのチャンネルを検索中…\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"주변 채널 찾는 중…\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mencari kanal sekitar…\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नजिकका च्यानल खोज्दै…\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kanalen in de buurt zoeken…\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wyszukiwanie kanałów w pobliżu…\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"a procurar canais próximos…\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"procurando canais próximos…\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"поиск каналов рядом…\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"söker efter kanaler i närheten…\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அருகிலுள்ள சேனல்கள் தேடப்படுகின்றன…\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"กำลังค้นหาช่องใกล้คุณ…\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"yakındaki kanallar aranıyor…\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"пошук каналів поруч…\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"قریب کے چینلز تلاش ہو رہے ہیں…\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đang tìm kênh gần…\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"正在寻找附近频道…\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"正在尋找附近頻道…\"\n          }\n        }\n      }\n    },\n    \"location_channels.mesh_label\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"মেশ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मेश\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesh\"\n          }\n        }\n      }\n    },\n    \"location_channels.permission_denied\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تم رفض إذن الموقع. فعّله في الإعدادات لاستخدام قنوات الموقع.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"লোকেশন অনুমতি অস্বীকৃত। লোকেশন চ্যানেল ব্যবহার করতে সেটিংসে সক্রিয় করুন।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"standortberechtigung verweigert. aktiviere sie in den einstellungen für standortkanäle.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"location permission denied. enable in settings to use location channels.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"permiso de ubicación denegado. Actívalo en Ajustes para usar los canales de ubicación.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tinanggihan ang pahintulot sa lokasyon. i-enable sa settings para magamit ang mga channel ng lokasyon.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"autorisation de localisation refusée. active-la dans réglages pour utiliser les canaux.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הרשאת מיקום נדחתה. אפשר בהגדרות כדי להשתמש בערוצי מיקום.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लोकेशन अनुमति अस्वीकृत। लोकेशन चैनल उपयोग करने के लिए सेटिंग्स में सक्षम करें।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"izin lokasi ditolak. aktifkan di pengaturan untuk memakai kanal lokasi.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"autorizzazione posizione negata. abilitala nelle impostazioni per usare i canali.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置情報の許可が拒否されました。チャンネルを使うには設定で許可してください。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"위치 권한이 거부되었습니다. 위치 채널을 사용하려면 설정에서 활성화하세요.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kebenaran lokasi ditolak. aktifkan dalam Tetapan untuk menggunakan kanal lokasi.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"स्थान अनुमति अस्वीकार। स्थान च्यानल प्रयोग गर्न सेटिङमा सक्षम गर।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"locatierechten geweigerd. schakel dit in instellingen in om locatiekanalen te gebruiken.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"odmówiono dostępu do lokalizacji. włącz w ustawieniach, aby korzystać z kanałów lokalizacji.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"permissão de localização negada. ativa nas definições para usar canais de localização.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"permissão de localização negada. habilite em ajustes para usar canais de localização.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"доступ к локации запрещён. включи разрешение в настройках, чтобы использовать каналы.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"platsbehörighet nekad. aktivera i inställningar för att använda platskanaler.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இட அனுமதி மறுக்கப்பட்டது. இட சேனல்களைப் பயன்படுத்த அமைப்பில் இயக்கவும்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ปฏิเสธการเข้าถึงตำแหน่ง เปิดในตั้งค่าเพื่อใช้ช่องตำแหน่ง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"konum izni reddedildi. konum kanallarını kullanmak için ayarlardan etkinleştirin.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"доступ до локації заборонено. увімкни дозвіл у налаштуваннях, щоб користуватися каналами.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لوکیشن کی اجازت مسترد کر دی گئی۔ لوکیشن چینلز استعمال کرنے کیلئے سیٹنگز میں فعال کریں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bị từ chối quyền vị trí. hãy bật trong cài đặt để dùng kênh vị trí.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置权限被拒。请在设置中启用以使用位置频道。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"位置權限被拒。請在設定中啟用以使用位置頻道。\"\n          }\n        }\n      }\n    },\n    \"location_channels.row_title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"few\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d أشخاص\"\n                    }\n                  },\n                  \"many\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d شخص\"\n                    }\n                  },\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d شخص\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d شخص\"\n                    }\n                  },\n                  \"two\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d شخصان\"\n                    }\n                  },\n                  \"zero\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d أشخاص\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d person\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d personen\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d person\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d people\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d persona\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d personas\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d personne\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d personnes\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"many\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d אנשים\"\n                    }\n                  },\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d אדם\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d אנשים\"\n                    }\n                  },\n                  \"two\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d אנשים\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d orang\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d orang\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d persona\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d persone\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d人\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d人\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d명\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d व्यक्ति\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d व्यक्तिहरू\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d pessoa\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d pessoas\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"few\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d человека\"\n                    }\n                  },\n                  \"many\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d человек\"\n                    }\n                  },\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d человек\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d человека\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"few\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d людини\"\n                    }\n                  },\n                  \"many\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d людей\"\n                    }\n                  },\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d людина\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d людини\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          },\n          \"substitutions\" : {\n            \"people_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d 人\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d 人\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ [%2$#@people_count@]\"\n          }\n        }\n      }\n    },\n    \"location_channels.subtitle_prefix\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%@ • %@\"\n          }\n        }\n      }\n    },\n    \"location_channels.subtitle_with_name\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%1$@ • %2$@\"\n          }\n        }\n      }\n    },\n    \"location_channels.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#قنوات الموقع\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#লোকেশন চ্যানেল\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#standort-kanäle\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#location channels\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#canales de ubicación\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#mga channel ng lokasyon\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#canaux localisation\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#ערוצי מיקום\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#लोकेशन चैनल\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#kanal lokasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#canali posizione\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#ロケーションチャンネル\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#위치 채널\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#kanal lokasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#स्थान च्यानल\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#locatiekanalen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#kanały lokalizacji\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#canais de localização\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#canais de localização\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#каналы локации\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#platskanaler\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#இட சேனல்கள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#ช่องตามตำแหน่ง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#konum kanalları\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#канали локації\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#لوکیشن چینلز\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#kênh vị trí\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#位置频道\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#位置頻道\"\n          }\n        }\n      }\n    },\n    \"location_channels.tor.subtitle\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"يخفي ip لقنوات الموقع. الموصى به: تشغيل.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"লোকেশন চ্যানেলের জন্য আপনার IP লুকায়। সুপারিশ: চালু রাখুন।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verbirgt deine ip für standortkanäle. empfohlen: an.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hides your IP for location channels. recommended: on.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"oculta tu IP para los canales de ubicación. Recomendado: activado.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"itinatago ang iyong IP para sa mga channel ng lokasyon. inirerekomenda: naka-on.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cache ton ip pour les canaux localisation. recommandé : activé.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מסתיר את ה-ip שלך לערוצי מיקום. מומלץ: פעיל.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लोकेशन चैनलों के लिए आपका IP छुपाता है। अनुशंसित: चालू रखें।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menyembunyikan ip-mu untuk kanal lokasi. disarankan: aktif.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nasconde il tuo ip per i canali posizione. consigliato: attivo.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ロケーションチャンネル用にipを隠します。推奨: オン\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"위치 채널에서 IP를 숨깁니다. 권장: 켜기.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menyembunyikan ip-mu untuk kanal lokasi. disarankan: aktif.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"स्थान च्यानलका लागि तिम्रो ip लुकाउँछ। सिफारिस: अन।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verbergt je IP voor locatiekanalen. aanbevolen: aan.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ukrywa twój IP dla kanałów lokalizacji. zalecane: włączone.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"oculta o teu IP para canais de localização. recomendado: ligado.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"oculta seu ip para canais de localização. recomendado: ligado.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"скрывает твой ip для каналов локации. рекомендуем включить.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"döljer din IP för platskanaler. rekommenderas: på.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இட சேனல்களுக்கு உங்கள் IP ஐ மறைக்கும். பரிந்துரை: இயக்கப்பட்டது.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ซ่อน IP ของคุณสำหรับช่องตำแหน่ง แนะนำให้เปิด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"konum kanalları için IP'nizi gizler. önerilen: açık.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"приховує твій ip для каналів локації. рекомендовано ввімкнути.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لوکیشن چینلز کیلئے آپ کا IP چھپاتا ہے۔ تجویز: آن رکھیں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ẩn IP của bạn cho kênh vị trí. khuyến nghị: bật.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"为位置频道隐藏你的 IP。推荐：开启。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"為位置頻道隱藏你的 IP。推薦：開啟。\"\n          }\n        }\n      }\n    },\n    \"location_channels.tor.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"توجيه tor\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor রাউটিং\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor-routing\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor routing\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enrutamiento Tor\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor routing\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"routage tor\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ניתוב tor\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor रूटिंग\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"perutean tor\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"instradamento tor\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"torルーティング\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor 라우팅\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"perutean tor\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor रूटिङ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor-routing\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"trasowanie tor\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"encaminhamento Tor\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"roteamento tor\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"маршрутизация tor\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor-routing\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor வழிமுறை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"การกำหนดเส้นทางผ่าน tor\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor yönlendirmesi\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"маршрутизація tor\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor روٹنگ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"định tuyến tor\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor 路由\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor 路由\"\n          }\n        }\n      }\n    },\n    \"location_levels.block\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مربع\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্লক\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"block\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"block\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloque\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloke\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloc\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"בלוק\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ब्लॉक\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blok\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"isolato\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ブロック\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"블록\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blok\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ब्लक\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blok\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blok\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bloco\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"quadra\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"квартал\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"block\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பிளாக்கு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"บล็อก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blok\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"квартал\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بلاک\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"khối\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"街区\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"街區\"\n          }\n        }\n      }\n    },\n    \"location_levels.building\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مبنى\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ভবন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gebäude\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"building\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"edificio\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gusali\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bâtiment\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מבנה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"भवन\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gedung\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"edificio\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"建物\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"건물\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gedung\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"भवन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gebouw\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"budynek\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"edifício\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"prédio\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"здание\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"byggnad\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"கட்டிடம்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"อาคาร\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bina\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"будівля\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"عمارت\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tòa nhà\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"楼栋\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"樓棟\"\n          }\n        }\n      }\n    },\n    \"location_levels.city\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مدينة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"শহর\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stadt\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"city\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ciudad\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lungsod\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ville\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"עיר\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"शहर\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kota\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"città\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"都市\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"도시\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kota\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सहर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stad\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"miasto\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cidade\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cidade\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"город\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stad\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"நகரம்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เมือง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"şehir\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"місто\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"شہر\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"thành phố\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"城市\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"城市\"\n          }\n        }\n      }\n    },\n    \"location_levels.neighborhood\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"حي\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"পাড়া\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"viertel\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"neighborhood\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"barrio\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"baranggay\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"quartier\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"שכונה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पड़ोस\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lingkungan\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"quartiere\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"近所\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"동네\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lingkungan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"छिमेक\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"buurt\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dzielnica\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bairro\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bairro\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"район\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"grannskap\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அயல்பகுதி\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ย่าน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mahalle\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"район\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"محلہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"khu vực\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"社区\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"社區\"\n          }\n        }\n      }\n    },\n    \"location_levels.province\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مقاطعة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"প্রদেশ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bundesland\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"province\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"provincia\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"probinsya\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"province\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"מחוז\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रांत\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"provinsi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"provincia\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"州\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"도\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"provinsi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रदेश\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"provincie\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"województwo\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"província\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"estado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"область\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"län\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"மாவட்டம்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"จังหวัด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"il\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"область\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"صوبہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tỉnh\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"省份\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"省份\"\n          }\n        }\n      }\n    },\n    \"location_levels.region\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"منطقة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"অঞ্চল\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"region\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"region\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"región\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"rehiyon\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"région\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אזור\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"क्षेत्र\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wilayah\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"regione\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"地域\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"지역\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wilayah\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"क्षेत्र\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"regio\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"region\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"região\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"região\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"регион\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"region\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பிராந்தியம்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ภูมิภาค\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bölge\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"регіон\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"خطہ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"vùng\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"区域\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"區域\"\n          }\n        }\n      }\n    },\n    \"location_notes.action.dismiss\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إغلاق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বন্ধ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"schließen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dismiss\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"descartar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"isara\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ignorer\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"סגור\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"बंद करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tutup\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chiudi\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"閉じる\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"닫기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tutup\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"बन्द गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sluiten\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zamknij\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fechar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dispensar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"закрыть\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"stäng\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"மூடு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ปิด\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kapat\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"закрити\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بند کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đóng\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"关闭\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"關閉\"\n          }\n        }\n      }\n    },\n    \"location_notes.action.retry\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"إعادة المحاولة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"আবার চেষ্টা করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"erneut versuchen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"retry\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"reintentar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"subukan muli\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"réessayer\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ניסיון שוב\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"फिर प्रयास करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"coba lagi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"riprova\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"再試行\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"재시도\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"coba lagi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"फेरि प्रयास गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"opnieuw proberen\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"spróbuj ponownie\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tentar novamente\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tentar novamente\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"повторить\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"försök igen\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"மீண்டும் முயற்சி\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ลองอีกครั้ง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"yeniden dene\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"повторити\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"دوبارہ کوشش کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"thử lại\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"重试\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"重試\"\n          }\n        }\n      }\n    },\n    \"location_notes.description\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"أضف ملاحظات قصيرة دائمة لهذا المكان ليجدها الآخرون.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এই স্থানে অন্যরা খুঁজে পেতে পারে এমন ছোট স্থায়ী নোট যোগ করুন।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"füge diesem ort kurze dauerhafte notizen hinzu, damit andere sie finden.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"add short permanent notes to this location for other visitors to find.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"añade notas permanentes cortas sobre este lugar para que otras personas las encuentren.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"magdagdag ng maiikling permanenteng tala sa lugar na ito para matagpuan ng iba\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ajoute de courtes notes permanentes ici pour aider les autres.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הוסף הערות קצרות וקבועות למקום הזה כדי שאחרים ימצאו.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"इस स्थान के लिए अन्य आगंतुकों को मिलने वाले छोटे स्थायी नोट जोड़ें।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tambahkan catatan permanen singkat di tempat ini agar orang lain menemukannya.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aggiungi brevi note permanenti su questo luogo per chi verrà.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"他の人が見つけられるようこの場所に短いノートを追加\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"다른 방문자가 볼 수 있도록 이 위치에 짧은 영구적 노트를 추가하세요.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tambahkan catatan permanen singkat di tempat ini agar orang lain menemukannya.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अन्यले भेटून् भनी यस स्थानमा छोटो स्थायी नोट थप।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"voeg korte, blijvende notities toe aan deze locatie zodat anderen ze kunnen vinden\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dodaj krótkie trwałe notatki o tej lokalizacji, aby inni mogli je znaleźć\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"adiciona pequenas notas permanentes a este local para outros visitantes encontrarem.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"adicione notas curtas permanentes neste local para outras pessoas encontrarem.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"добавь короткие постоянные заметки об этом месте для других.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lägg till korta beständiga anteckningar för denna plats så att andra hittar dem\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இந்த இடத்திற்கு பிறர் கண்டுபிடிக்க சிறிய நிரந்தர குறிப்புகளைச் சேர்க்கவும்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เพิ่มบันทึกถาวรสั้น ๆ ให้สถานที่นี้เพื่อให้ผู้มาเยือนคนอื่นพบได้\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bu konuma gelen diğer ziyaretçilerin bulması için kısa kalıcı notlar ekleyin.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"додай короткі постійні замітки про це місце для інших.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"اس جگہ کیلئے مختصر مستقل نوٹس شامل کریں تاکہ دوسرے دیکھ سکیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"thêm ghi chú ngắn cố định cho nơi này để người khác tìm thấy\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"为此地点添加简短的常驻笔记，方便其他访客发现。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"為此地點添加簡短的常駐筆記，方便其他訪客發現。\"\n          }\n        }\n      }\n    },\n    \"location_notes.empty_subtitle\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"كن أول من يضيف هنا.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এই জায়গার জন্য প্রথম নোট যোগ করুন।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sei die erste person, die hier eine notiz hinterlässt.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"be the first to add one for this spot.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sé la primera persona en añadir una en este lugar.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"maging unang magdagdag para sa puntong ito\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sois la première personne à en ajouter ici.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"היה הראשון להוסיף כאן.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"इस जगह के लिए पहला नोट आप जोड़ें।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"jadilah orang pertama yang menambahkannya di sini.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"fai tu la prima nota qui.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ここで最初のノートを残そう。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"이 장소에 첫 번째 노트를 남겨보세요.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"jadilah orang pertama yang menambahkannya di sini.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"यस ठाउँमा नोट थप्ने पहिलो व्यक्ती बन।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wees de eerste die er hier een toevoegt\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dodaj pierwszą notatkę dla tego miejsca\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sê o primeiro a adicionar uma nota para este sítio.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"seja a primeira pessoa a adicionar uma aqui.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"стань первым, кто добавит здесь заметку.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"var först med en anteckning här\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இந்த இடத்திற்கு முதலில் ஒரு குறிப்பைச் சேர்க்கவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เป็นคนแรกที่เพิ่มบันทึกให้จุดนี้\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bu yer için ilk notu sen ekle.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"стань першим, хто додасть тут замітку.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"اس مقام کیلئے پہلا نوٹ آپ شامل کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hãy là người đầu tiên thêm ghi chú tại đây\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"成为这里的第一条笔记。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"成為這裡的第一條筆記。\"\n          }\n        }\n      }\n    },\n    \"location_notes.empty_title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لا توجد ملاحظات بعد\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এখনও কোনো নোট নেই\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"noch keine notizen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no notes yet\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aún no hay notas\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wala pang tala\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pas encore de notes\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אין הערות עדיין\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अभी कोई नोट नहीं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"belum ada catatan\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ancora nessuna nota\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ノートはまだありません\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"아직 노트가 없습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"belum ada catatan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अहिले नोट छैन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nog geen notities\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"brak notatek\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ainda sem notas\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nenhuma nota ainda\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заметок пока нет\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"inga anteckningar än\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இன்னும் குறிப்புகள் இல்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ยังไม่มีบันทึก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"henüz not yok\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заміток ще немає\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ابھی تک کوئی نوٹس نہیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chưa có ghi chú\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"尚无笔记\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"尚無筆記\"\n          }\n        }\n      }\n    },\n    \"location_notes.error.failed_to_send\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تعذر إرسال الملاحظة. %@\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"নোট পাঠাতে ব্যর্থ। %@\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notiz konnte nicht gesendet werden. %@\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"failed to send note. %@\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no se pudo enviar la nota. %@\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nabigong magpadala ng tala. %@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossible d'envoyer la note. %@\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"לא ניתן לשלוח את ההערה. %@\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नोट भेजने में विफल। %@\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa mengirim catatan. %@\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossibile inviare la nota. %@\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ノートを送信できませんでした。%@\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"노트 전송 실패. %@\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa menghantar catatan. %@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नोट पठाउन सकेन। %@\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"versturen van notitie mislukt. %@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nie udało się wysłać notatki. %@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"falha ao enviar a nota. %@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não foi possível enviar a nota. %@\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не удалось отправить заметку. %@\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kunde inte skicka anteckning. %@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறிப்பை அனுப்ப முடியவில்லை. %@\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ส่งบันทึกล้มเหลว %@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"not gönderilemedi. %@\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не вдалося надіслати замітку. %@\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نوٹ بھیجنا ناکام۔ %@\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gửi ghi chú thất bại. %@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"无法发送笔记。%@\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"無法發送筆記。%@\"\n          }\n        }\n      }\n    },\n    \"location_notes.error.no_relays\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لا توجد مرحلات جغرافية قريبة من هذا المكان. حاول لاحقاً.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এই স্থানের কাছে কোনো জিও রিলে নেই। কিছুক্ষণ পরে আবার চেষ্টা করুন।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"keine geo-relays in der nähe verfügbar. versuch es später erneut.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no geo relays available near this location. try again soon.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no hay relays geográficos disponibles cerca de este lugar. Inténtalo de nuevo pronto.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"walang geo relay malapit sa lokasyong ito. subukan muli maya-maya.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aucun relais géo disponible près d'ici. réessaie bientôt.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אין ממסרי geo זמינים בקרבת מקום. נסה שוב מאוחר יותר.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"इस स्थान के पास कोई जियो रिले उपलब्ध नहीं। कुछ देर बाद फिर प्रयास करें।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak ada relay geo tersedia dekat sini. coba lagi nanti.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nessun relay geo disponibile qui vicino. riprova presto.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"近くに利用できるジオリレーがありません。後で再試行してください。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"이 위치 근처에 사용 가능한 geo 릴레이가 없습니다. 잠시 후 다시 시도하세요.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak ada relay geo tersedia dekat sini. coba lagi nanti.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"यस स्थान नजिक georelay उपलब्ध छैन। केही बेरपछि प्रयास गर।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geen geo-relays in de buurt van deze locatie. probeer het later opnieuw.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"brak geo relay w pobliżu tej lokalizacji. spróbuj ponownie wkrótce.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sem relés geográficos disponíveis perto deste local. tenta novamente em breve.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nenhum relay geográfico disponível perto deste local. tente novamente em breve.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"рядом нет георелеев. попробуй позже.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"inga geo-reläer nära denna plats. försök igen senare.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இந்த இடத்துக்கு அருகில் geo relay இல்லை. கொஞ்சம் நேரம் கழித்து முயற்சி செய்யவும்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่มี geo relay ใกล้สถานที่นี้ ลองอีกครั้งในภายหลัง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bu konuma yakın geo röle yok. birazdan yeniden deneyin.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"поруч немає гео-релеїв. спробуй пізніше.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"اس جگہ کے قریب کوئی geo relay دستیاب نہیں۔ کچھ دیر بعد دوبارہ کوشش کریں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không có relay địa lý gần khu vực này. thử lại sau.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"附近没有可用的地理中继。稍后再试。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"附近沒有可用的地理中繼。稍後再試。\"\n          }\n        }\n      }\n    },\n    \"location_notes.header\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"few\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d ملاحظات\"\n                    }\n                  },\n                  \"many\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d ملاحظة\"\n                    }\n                  },\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d ملاحظة\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d ملاحظة\"\n                    }\n                  },\n                  \"two\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d ملاحظتان\"\n                    }\n                  },\n                  \"zero\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d ملاحظات\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d notiz\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d notizen\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d note\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d notes\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d nota\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d notas\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d note\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d notes\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"many\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d הערות\"\n                    }\n                  },\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d הערה\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d הערות\"\n                    }\n                  },\n                  \"two\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d הערות\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d catatan\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d catatan\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d nota\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d note\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d件のノート\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d件のノート\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d개의 노트\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d नोट\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d नोटहरू\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d nota\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d notas\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"few\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d заметки\"\n                    }\n                  },\n                  \"many\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d заметок\"\n                    }\n                  },\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d заметка\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d заметки\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"few\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d замітки\"\n                    }\n                  },\n                  \"many\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d заміток\"\n                    }\n                  },\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d замітка\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d замітки\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          },\n          \"substitutions\" : {\n            \"note_count\" : {\n              \"argNum\" : 2,\n              \"formatSpecifier\" : \"lld\",\n              \"variations\" : {\n                \"plural\" : {\n                  \"one\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d 条笔记\"\n                    }\n                  },\n                  \"other\" : {\n                    \"stringUnit\" : {\n                      \"state\" : \"translated\",\n                      \"value\" : \"%d 条笔记\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"#%1$@ • %2$#@note_count@\"\n          }\n        }\n      }\n    },\n    \"location_notes.loading_notes\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"جار تحميل الملاحظات…\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"নোট লোড হচ্ছে…\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notizen werden geladen…\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"loading notes…\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cargando notas…\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nagse-load ng mga tala…\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chargement des notes…\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"טוען הערות…\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नोट लोड हो रहे हैं…\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"memuat catatan…\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"caricamento note…\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ノートを読み込み中…\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"노트 로딩 중…\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"memuat catatan…\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नोट लोड हुँदै…\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notities laden…\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ładowanie notatek…\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"a carregar notas…\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"carregando notas…\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"загрузка заметок…\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"laddar anteckningar…\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறிப்புகள் ஏற்றப்படுகின்றன…\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"กำลังโหลดบันทึก…\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notlar yükleniyor…\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"завантаження заміток…\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نوٹس لوڈ ہو رہے ہیں…\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đang tải ghi chú…\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"正在加载笔记…\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"正在加載筆記…\"\n          }\n        }\n      }\n    },\n    \"location_notes.loading_recent\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"جار تحميل الملاحظات الحديثة…\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"সাম্প্রতিক নোট লোড হচ্ছে…\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aktuelle notizen werden geladen…\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"loading recent notes…\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cargando notas recientes…\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nagse-load ng pinakahuling mga tala…\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chargement des notes récentes…\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"טוען הערות אחרונות…\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"हाल के नोट लोड हो रहे हैं…\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"memuat catatan terbaru…\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"caricamento note recenti…\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"最新ノートを読み込み中…\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"최근 노트 로딩 중…\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"memuat catatan terbaru…\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"हालैका नोट लोड गर्दै…\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"recentste notities laden…\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ładowanie ostatnich notatek…\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"a carregar notas recentes…\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"carregando notas recentes…\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"загрузка свежих заметок…\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"laddar senaste anteckningar…\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சமீபத்திய குறிப்புகள் ஏற்றப்படுகின்றன…\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"กำลังโหลดบันทึกล่าสุด…\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"son notlar yükleniyor…\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"завантаження свіжих заміток…\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"حالیہ نوٹس لوڈ ہو رہے ہیں…\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đang tải ghi chú gần đây…\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"正在加载最新笔记…\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"正在加載最新筆記…\"\n          }\n        }\n      }\n    },\n    \"location_notes.no_relays_nearby\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لا مرحلات جغرافية قريبة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"কাছে কোনো জিও রিলে নেই\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"keine geo-relays in der nähe\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no geo relays nearby\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no hay relays geográficos cercanos\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"walang malapit na geo relay\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aucun relais géo à proximité\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אין ממסרי geo קרובים\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पास कोई जियो रिले नहीं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak ada relay geo di dekatmu\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nessun relay geo vicino\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"近くにジオリレーなし\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"근처에 geo 릴레이가 없습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak ada relay geo di dekatmu\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नजिक georelay छैन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geen geo-relays in de buurt\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"brak pobliskich geo relay\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sem relés geográficos por perto\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nenhum relay geográfico próximo\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"рядом нет георелеев\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"inga geo-reläer i närheten\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அருகில் geo relay இல்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่มี geo relay ใกล้เคียง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"yakında geo röle yok\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"немає гео-релеїв поблизу\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"قریب کوئی geo relay نہیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không có relay địa lý gần đó\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"附近没有地理中继\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"附近沒有地理中繼\"\n          }\n        }\n      }\n    },\n    \"location_notes.placeholder\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"أضف ملاحظة لهذا المكان\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"এই স্থানের জন্য একটি নোট যোগ করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notiz für diesen ort hinzufügen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"add a note for this place\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"añade una nota para este lugar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"magdagdag ng tala para sa lugar na ito\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ajoute une note pour cet endroit\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הוסף הערה למקום הזה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"इस स्थान के लिए नोट जोड़ें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tambahkan catatan untuk tempat ini\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aggiungi una nota per questo posto\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"この場所のノートを追加\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"이 장소에 대한 노트 추가\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tambahkan catatan untuk tempat ini\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"यस स्थानका लागि नोट थप\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"voeg een notitie toe voor deze plek\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dodaj notatkę do tego miejsca\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"adiciona uma nota para este local\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"adicione uma nota para este lugar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"добавь заметку для этого места\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lägg till en anteckning för den här platsen\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இந்த இடத்திற்கான ஒரு குறிப்பைச் சேர்க்கவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เพิ่มบันทึกให้สถานที่นี้\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bu yer için bir not ekleyin\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"додай замітку для цього місця\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"اس جگہ کیلئے نوٹ شامل کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"thêm ghi chú cho nơi này\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"为此地点添加笔记\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"為此地點添加筆記\"\n          }\n        }\n      }\n    },\n    \"location_notes.relays_paused\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"المرحلات الجغرافية غير متاحة؛ الملاحظات متوقفة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"জিও রিলে অনুপলব্ধ; নোট স্থগিত\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geo-relays nicht verfügbar; notizen pausiert\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geo relays unavailable; notes paused\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"relays geográficos no disponibles; notas en pausa\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi magagamit ang mga geo relay; naka-pause ang mga tala\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"relais géo indisponibles ; notes en pause\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ממסרי geo אינם זמינים; הערות הושהו\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"जियो रिले अनुपलब्ध; नोट रुके हैं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"relay geo tidak tersedia; catatan dijeda\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"relay geo non disponibili; note in pausa\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ジオリレーが利用不可: ノート一時停止\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geo 릴레이를 사용할 수 없습니다; 노트가 일시 중지됩니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"relay geo tidak tersedia; catatan dijeda\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"georelay उपलब्ध छैन; नोट रोकिएको\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geo-relays niet beschikbaar; notities gepauzeerd\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geo relay niedostępne; notatki wstrzymane\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"relés geográficos indisponíveis; notas em pausa\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"relays geográficos indisponíveis; notas pausadas\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"геореле недоступны; заметки приостановлены\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geo-reläer otillgängliga; anteckningar pausade\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geo relay கிடைக்கவில்லை; குறிப்புகள் இடைநிறுத்தப்பட்டுள்ளன\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geo relay ไม่พร้อมใช้งาน บันทึกถูกหยุดชั่วคราว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geo röleler kullanılamıyor; notlar durduruldu\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"гео-релеї недоступні; замітки призупинено\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geo relay دستیاب نہیں؛ نوٹس موقوف\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"relay địa lý không sẵn có; ghi chú bị tạm dừng\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"地理中继不可用；笔记已暂停\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"地理中繼不可用；筆記已暫停\"\n          }\n        }\n      }\n    },\n    \"location_notes.relays_retry_hint\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"الملاحظات تعتمد على المرحلات الجغرافية. تحقق من الاتصال ثم أعد المحاولة.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"নোট জিও রিলের উপর নির্ভরশীল। সংযোগ পরীক্ষা করে আবার চেষ্টা করুন।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notizen hängen von geo-relays ab. prüfe die verbindung und versuch es erneut.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notes rely on geo relays. check connection and try again.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"las notas dependen de los relays geográficos. Comprueba la conexión e inténtalo de nuevo.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"umaasa ang mga tala sa mga geo relay. suriin ang koneksyon at subukan ulit.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"les notes dépendent des relais géo. vérifie la connexion et réessaie.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הערות תלויות בממסרי geo. בדוק את החיבור ונסה שוב.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नोट जियो रिले पर निर्भर हैं। कनेक्शन जांचें और फिर प्रयास करें।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"catatan bergantung pada relay geo. cek koneksi lalu coba lagi.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"le note dipendono dai relay geo. controlla la connessione e riprova.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ノートはジオリレーに依存します。接続を確認して再試行してください。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"노트는 geo 릴레이를 사용합니다. 연결을 확인하고 다시 시도하세요.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"catatan bergantung pada relay geo. cek koneksi lalu coba lagi.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नोट georelay मा निर्भर छन्। जडान जाँच गरेर फेरि प्रयास गर.\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notities zijn afhankelijk van geo-relays. controleer de verbinding en probeer opnieuw.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notatki polegają na geo relay. sprawdź połączenie i spróbuj ponownie.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"as notas dependem de relés geográficos. verifica a ligação e tenta de novo.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notas dependem de relays geográficos. verifique a conexão e tente de novo.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"заметки зависят от георелеев. проверь подключение и попробуй снова.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"anteckningar är beroende av geo-reläer. kontrollera uppkopplingen och försök igen.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குறிப்புகள் geo relay மீது நம்புகிறது. இணைப்பைச் சரிபார்த்து மீண்டும் முயற்சிக்கவும்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"บันทึกอาศัย geo relay ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"notlar geo rölelere bağlıdır. bağlantıyı kontrol edip tekrar deneyin.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"замітки залежать від гео-релеїв. перевір з'єднання й спробуй ще раз.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نوٹس geo relay پر منحصر ہیں۔ کنکشن چیک کریں اور دوبارہ کوشش کریں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ghi chú phụ thuộc vào relay địa lý. kiểm tra kết nối rồi thử lại.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"笔记依赖地理中继。检查连接后再试。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"筆記依賴地理中繼。檢查連線後再試。\"\n          }\n        }\n      }\n    },\n    \"mesh_peers.tooltip.new_messages\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"رسائل جديدة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"নতুন বার্তা\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"neue nachrichten\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"new messages\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nuevos mensajes\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"may bagong mensahe\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nouveaux messages\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הודעות חדשות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नए संदेश\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pesan baru\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nuovi messaggi\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"新しいメッセージ\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"새 메시지\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pesan baru\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"नयाँ सन्देश\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nieuwe berichten\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nowe wiadomości\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"novas mensagens\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"novas mensagens\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"новые сообщения\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nya meddelanden\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"புதிய செய்திகள்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"มีข้อความใหม่\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"yeni mesajlar\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"нові повідомлення\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نئے پیغامات\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tin nhắn mới\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"新消息\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"新訊息\"\n          }\n        }\n      }\n    },\n    \"recording %@\" : {\n      \"comment\" : \"Voice note recording duration indicator\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"جارٍ التسجيل %@\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"রেকর্ডিং %@\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aufnahme %@\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"recording %@\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"grabando %@\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nagre-record %@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enregistrement %@\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הקלטה %@\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"रिकॉर्डिंग %@\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"merekam %@\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"registrazione %@\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"録音中 %@\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"녹음 중 %@\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"merakam %@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"रेकर्डिङ %@\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"opname %@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nagrywanie %@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"a gravar %@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gravando %@\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"идет запись %@\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"spelar in %@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பதிவு %@\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"กำลังบันทึก %@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kaydediliyor %@\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"йде запис %@\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ریکارڈنگ %@\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đang ghi %@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"录音中 %@\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"錄音中 %@\"\n          }\n        }\n      }\n    },\n    \"save\" : {\n      \"comment\" : \"Button to save media to device\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"حفظ\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"সংরক্ষণ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"speichern\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"save\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"guardar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-save\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"enregistrer\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"שמור\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सहेजें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"simpan\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"salva\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"保存\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"저장\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"simpan\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सेभ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"opslaan\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zapisz\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"guardar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"salvar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"сохранить\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"spara\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சேமிக்கவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"บันทึก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kaydet\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"зберегти\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"محفوظ کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lưu\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"保存\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"儲存\"\n          }\n        }\n      }\n    },\n    \"system.chat.blocked\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لا يمكن بدء دردشة مع %@: المستخدم محظور.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@-এর সঙ্গে চ্যাট শুরু করা যায় না: ব্যক্তি ব্লক করা আছে।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chat mit %@ kann nicht gestartet werden: nutzer blockiert.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cannot start chat with %@: person is blocked.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no se puede iniciar un chat con %@: el usuario está bloqueado.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi makapagsimula ng chat kay %@: na-block ang tao.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossible de démarrer un chat avec %@ : utilisateur bloqué.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"לא ניתן להתחיל צ'אט עם %@: המשתמש חסום.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ के साथ चैट शुरू नहीं कर सकते: व्यक्ति ब्लॉक है।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa mulai chat dengan %@: pengguna diblokir.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossibile avviare una chat con %@: utente bloccato.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@とはチャットできません: ユーザーをブロック中。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@와 채팅을 시작할 수 없습니다: 차단된 사용자입니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa mulai chat dengan %@: pengguna diblokir.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ सँग च्याट सुरु गर्न मिलेन: प्रयोगकर्ता ब्लक गरिएको\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan chat met %@ niet starten: persoon is geblokkeerd.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nie można rozpocząć czatu z %@: osoba zablokowana.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não é possível iniciar o chat com %@: a pessoa está bloqueada.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não é possível iniciar chat com %@: usuário bloqueado.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"нельзя начать чат с %@: пользователь заблокирован.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan inte starta chatt med %@: personen är blockerad.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ உடன் உரையாடல் தொடங்க முடியாது: அந்த நபர் தடுக்கப்பட்டுள்ளார்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่สามารถเริ่มแชทกับ %@: บุคคลนี้ถูกบล็อก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ ile sohbet başlatılamıyor: kişi engelli.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не можна почати чат з %@: користувач заблокований.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ کے ساتھ چیٹ شروع نہیں کر سکتے: شخص بلاک ہے۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không thể bắt đầu chat với %@: người này bị chặn.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"无法与 %@ 开始聊天：用户已被屏蔽。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"無法與 %@ 開始聊天：使用者已被屏蔽。\"\n          }\n        }\n      }\n    },\n    \"system.chat.requires_favorite\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لا يمكن بدء دردشة مع %@: يجب أن تكونا مفضلين متبادلين للتشغيل بدون اتصال.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@-এর সঙ্গে চ্যাট শুরু করা যায় না: অফলাইন মেসেজিংয়ের জন্য পারস্পরিক প্রিয় দরকার।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"chat mit %@ kann nicht gestartet werden: gegenseitige favoriten für offline nötig.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cannot start chat with %@: mutual favorite required for offline messaging.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no se puede iniciar un chat con %@: necesitas ser favoritos mutuos para mensajería sin conexión.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi makapagsimula ng chat kay %@: kailangan ang mutual na paborito para sa offline na mensahe.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossible de démarrer un chat avec %@ : favoris mutuels requis pour le hors ligne.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"לא ניתן להתחיל צ'אט עם %@: נדרשים מועדפים הדדיים לאופליין.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ के साथ चैट शुरू नहीं कर सकते: ऑफ़लाइन मैसेजिंग के लिए आपसी पसंदीदा आवश्यक है।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa mulai chat dengan %@: butuh favorit bersama untuk offline.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossibile avviare una chat con %@: servono preferiti reciproci per l'offline.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@とはチャットできません: オフラインには相互のお気に入りが必要です。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@와 채팅을 시작할 수 없습니다: 오프라인 메시지를 보내려면 서로 즐겨찾기에 추가해야 합니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa mulai chat dengan %@: butuh favorit bersama untuk offline.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ सँग च्याट सुरु गर्न मिलेन: अफलाइनका लागि दुवै मनपर्ने हुनुपर्छ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan chat met %@ niet starten: wederzijds favoriet vereist voor offline berichten.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nie można rozpocząć czatu z %@: potrzebne wzajemne ulubione dla wiadomości offline.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não é possível iniciar o chat com %@: precisam de ser favoritos mútuos para mensagens offline.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não é possível iniciar chat com %@: vocês precisam ser favoritos mútuos para mensagens offline.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"нельзя начать чат с %@: нужны взаимные избранные для офлайна.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan inte starta chatt med %@: ömsesidig favorit krävs för offline-meddelanden.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ உடன் உரையாடல் தொடங்க முடியாது: ஆஃப்லைன் செய்திகளுக்கு இருபுறமும் பிரியப்பட்டவர்கள் ஆக வேண்டும்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่สามารถเริ่มแชทกับ %@: ต้องเป็นรายการโปรดทั้งสองฝ่ายเพื่อใช้งานออฟไลน์\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ ile sohbet başlatılamıyor: çevrimdışı mesajlaşma için karşılıklı favori gerekiyor.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не можна почати чат з %@: потрібне взаємне вибране для офлайна.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ کے ساتھ چیٹ شروع نہیں کر سکتے: آفلائن پیغامات کیلئے باہمی پسندیدہ ہونا ضروری ہے۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"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.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"无法与 %@ 开始聊天：离线消息需要互相关注。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"無法與 %@ 開始聊天：離線訊息需要互相關注。\"\n          }\n        }\n      }\n    },\n    \"system.common.user\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مستخدم\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ব্যবহারকারী\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nutzer\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"user\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"usuario\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"user\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"utilisateur\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"משתמש\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"उपयोगकर्ता\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pengguna\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"utente\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ユーザー\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"사용자\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pengguna\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रयोगकर्ता\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gebruiker\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"użytkownik\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"utilizador\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"usuário\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"пользователь\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"användare\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பயனர்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ผู้ใช้\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kullanıcı\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"користувач\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"صارف\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"người dùng\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"用户\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"使用者\"\n          }\n        }\n      }\n    },\n    \"system.dm.blocked_generic\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تعذر الإرسال: المستخدم محظور.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বার্তা পাঠানো যায় না: ব্যক্তি ব্লক করা আছে।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"senden nicht möglich: nutzer blockiert.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cannot send message: person is blocked.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no se puede enviar el mensaje: el usuario está bloqueado.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi makapagpadala ng mensahe: na-block ang tao.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"envoi impossible : utilisateur bloqué.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אי אפשר לשלוח: המשתמש חסום.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"संदेश नहीं भेज सकते: व्यक्ति ब्लॉक है।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa mengirim: pengguna diblokir.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"invio non riuscito: utente bloccato.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"送信できません: ユーザーをブロック中。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"메시지를 보낼 수 없습니다: 차단된 사용자입니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa menghantar: pengguna diblokir.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पठाउन मिलेन: प्रयोगकर्ता ब्लक\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan bericht niet sturen: persoon is geblokkeerd.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nie można wysłać wiadomości: osoba zablokowana.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não é possível enviar mensagem: a pessoa está bloqueada.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não foi possível enviar: usuário bloqueado.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"отправка невозможна: пользователь заблокирован.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan inte skicka meddelande: personen är blockerad.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"செய்தி அனுப்ப முடியவில்லை: நபர் தடுக்கப்பட்டுள்ளார்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่สามารถส่งข้อความ: บุคคลนี้ถูกบล็อก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mesaj gönderilemiyor: kişi engelli.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не вдалося надіслати: користувач заблокований.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"پیغام نہیں بھیج سکتے: شخص بلاک ہے۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không thể gửi tin: người này bị chặn.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"无法发送：用户已被屏蔽。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"無法發送：使用者已被屏蔽。\"\n          }\n        }\n      }\n    },\n    \"system.dm.blocked_recipient\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لا يمكن الإرسال إلى %@: المستخدم محظور.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@-কে বার্তা পাঠানো যায় না: ব্যক্তি ব্লক করা আছে।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"senden an %@ nicht möglich: nutzer blockiert.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cannot send message to %@: person is blocked.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no se puede enviar un mensaje a %@: el usuario está bloqueado.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi maipadala kay %@: na-block ang tao.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossible d'envoyer à %@ : utilisateur bloqué.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אי אפשר לשלוח ל-%@: המשתמש חסום.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ को संदेश नहीं भेज सकते: व्यक्ति ब्लॉक है।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa mengirim ke %@: pengguna diblokir.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossibile inviare a %@: utente bloccato.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@に送れません: ユーザーをブロック中。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@에게 메시지를 보낼 수 없습니다: 차단된 사용자입니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa menghantar ke %@: pengguna diblokir.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ लाई पठाउन मिलेन: प्रयोगकर्ता ब्लक\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan geen bericht sturen naar %@: persoon is geblokkeerd.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nie można wysłać do %@: osoba zablokowana.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não é possível enviar mensagem a %@: a pessoa está bloqueada.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não é possível enviar mensagem para %@: usuário bloqueado.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"нельзя отправить %@: пользователь заблокирован.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan inte skicka till %@: personen är blockerad.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ க்கு செய்தி அனுப்ப முடியவில்லை: நபர் தடுக்கப்பட்டுள்ளார்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่สามารถส่งข้อความถึง %@: บุคคลนี้ถูกบล็อก\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@'a mesaj gönderilemiyor: kişi engelli.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"неможливо надіслати %@: користувач заблокований.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ کو پیغام نہیں بھیج سکتے: شخص بلاک ہے۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không thể gửi tin cho %@: người này bị chặn.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"无法向 %@ 发送：用户已被屏蔽。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"無法向 %@ 發送：使用者已被屏蔽。\"\n          }\n        }\n      }\n    },\n    \"system.dm.unreachable\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لا يمكن الإرسال إلى %@: المستلم غير متاح عبر mesh أو nostr.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@-কে বার্তা পাঠানো যায় না - পিয়ার মেশ বা নোস্টরে পৌঁছানো যাচ্ছে না।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"senden an %@ nicht möglich: empfänger über mesh oder nostr nicht erreichbar.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cannot send message to %@ - peer is not reachable via mesh or nostr.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no se puede enviar un mensaje a %@: el destinatario no es alcanzable por mesh ni Nostr.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi maipadala kay %@ - hindi maabot ang peer sa mesh o nostr.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossible d'envoyer à %@ : destinataire injoignable via mesh ou nostr.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אי אפשר לשלוח ל-%@: הנמען אינו נגיש דרך mesh או nostr.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ को संदेश नहीं भेज सकते - पीयर मेश या नोस्ट्र पर पहुँच योग्य नहीं।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa mengirim ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossibile inviare a %@: destinatario irraggiungibile via mesh o nostr.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@に送れません: 受信者はmeshやnostrで到達できません。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@에게 메시지를 보낼 수 없습니다 - mesh 또는 nostr를 통해 피어에 연결할 수 없습니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak bisa menghantar ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ लाई पठाउन मिलेन: प्राप्तकर्ता mesh वा nostr बाट उपलब्ध छैन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan geen bericht sturen naar %@ – peer niet bereikbaar via mesh of nostr.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nie można wysłać do %@ – peer nieosiągalny przez mesh ani nostr.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não é possível enviar mensagem a %@ - o par não está acessível por mesh ou Nostr.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não é possível enviar mensagem para %@: destinatário inalcançável por mesh ou nostr.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"нельзя отправить %@: адресат недоступен через mesh или nostr.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan inte skicka till %@ – peer nås inte via mesh eller nostr.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ க்கு செய்தி அனுப்ப முடியவில்லை - peer mesh அல்லது nostr மூலமாக அணுக முடியவில்லை.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่สามารถส่งข้อความถึง %@ - ติดต่อเพียร์ผ่าน mesh หรือ nostr ไม่ได้\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@'a mesaj gönderilemiyor - eş mesh veya Nostr üzerinden ulaşılamıyor.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"неможливо надіслати %@: одержувач недосяжний через mesh або nostr.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ کو پیغام نہیں بھیج سکتے - peer mesh یا nostr کے ذریعے دستیاب نہیں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không thể gửi tin cho %@ - nút không thể liên lạc qua mesh hoặc nostr.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"无法向 %@ 发送：对方无法通过 mesh 或 Nostr 到达。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"無法向 %@ 發送：對方無法透過 mesh 或 Nostr 到達。\"\n          }\n        }\n      }\n    },\n    \"system.geohash.blocked\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تم حظر %@ في محادثات geohash\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"জিওহ্যাশ চ্যাটে %@ ব্লক করা হয়েছে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ wurde in geohash-chats blockiert\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blocked %@ in geohash chats\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"se bloqueó a %@ en los chats geohash\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"na-block si %@ sa geohash chats\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ a été bloqué dans les chats geohash\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ נחסם בצ'אטי geohash\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"जियोहैश चैट में %@ ब्लॉक किया गया\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ diblokir di chat geohash\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ è stato bloccato nei chat geohash\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@をgeohashチャットでブロックしました\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash 채팅에서 %@을(를) 차단했습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ diblokir di chat geohash\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ लाई geohash च्याटमा ब्लक गरियो\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ geblokkeerd in geohash-chats\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zablokowano %@ w czatach geohash\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ bloqueado nos chats geohash\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ foi bloqueado nos chats geohash\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ заблокирован в geohash-чатах\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"blockerade %@ i geohash-chattar\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash உரையாடல்களில் %@ தடுக்கப்பட்டார்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"บล็อก %@ ในแชท geohash\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash sohbetlerinde %@ engellendi\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ заблоковано в geohash-чатах\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash چیٹس میں %@ کو بلاک کیا\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đã chặn %@ trong chat geohash\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已在 geohash 聊天中屏蔽 %@\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已在 geohash 聊天中屏蔽 %@\"\n          }\n        }\n      }\n    },\n    \"system.geohash.unblocked\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تم إلغاء حظر %@ في محادثات geohash\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"জিওহ্যাশ চ্যাটে %@ আনব্লক করা হয়েছে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ wurde in geohash-chats entsperrt\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"unblocked %@ in geohash chats\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"se desbloqueó a %@ en los chats geohash\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"in-unblock si %@ sa geohash chats\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ a été débloqué dans les chats geohash\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ הוסר מהחסימה בצ'אטי geohash\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"जियोहैश चैट में %@ अनब्लॉक किया गया\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ dibuka blokirnya di chat geohash\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ è stato sbloccato nei chat geohash\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@のgeohashチャットでのブロックを解除しました\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash 채팅에서 %@의 차단을 해제했습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ dibuka blokirnya di chat geohash\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ लाई geohash च्याटमा अनब्लक गरियो\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ gedeblokkeerd in geohash-chats\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"odblokowano %@ w czatach geohash\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ desbloqueado nos chats geohash\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ foi desbloqueado nos chats geohash\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ разблокирован в geohash-чатах\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"avblockerade %@ i geohash-chattar\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash உரையாடல்களில் %@ தடை நீக்கப்பட்டார்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เลิกบล็อก %@ ในแชท geohash\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash sohbetlerinde %@ engeli kaldırıldı\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ розблоковано в geohash-чатах\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geohash چیٹس میں %@ کا بلاک ہٹایا\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đã bỏ chặn %@ trong chat geohash\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已在 geohash 聊天中解除屏蔽 %@\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已在 geohash 聊天中解除屏蔽 %@\"\n          }\n        }\n      }\n    },\n    \"system.location.not_in_channel\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تعذر الإرسال: لست داخل قناة موقع\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"পাঠানো যায় না: লোকেশন চ্যানেলে নেই\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"senden fehlgeschlagen: du bist nicht in einem standortkanal\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cannot send: not in a location channel\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no se puede enviar: no estás en un canal de ubicación\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hindi maipadala: wala ka sa channel ng lokasyon\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"envoi impossible : tu n'es pas dans un canal localisation\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אי אפשר לשלוח: אינך בערוץ מיקום\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"भेज नहीं सकते: लोकेशन चैनल में नहीं हैं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gagal mengirim: kamu tidak berada di kanal lokasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"invio fallito: non sei in un canale posizione\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"送信失敗: ロケーションチャンネルに参加していません\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"전송할 수 없음: 위치 채널에 있지 않습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gagal menghantar: kamu tidak berada di kanal lokasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"पठाउन मिलेन: तिमी स्थान च्यानलमा छैनौ\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan niet versturen: je zit niet in een locatiekanaal\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nie można wysłać: nie jesteś w kanale lokalizacji\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não foi possível enviar: não estás num canal de localização\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não foi possível enviar: você não está em um canal de localização\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"отправка невозможна: ты не в канале локации\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kan inte skicka: du är inte i ett platskanal\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"அனுப்ப முடியாது: நீங்கள் இட சேனலில் இல்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ส่งไม่ได้: คุณไม่ได้อยู่ในช่องตำแหน่ง\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gönderilemiyor: konum kanalında değilsiniz\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не вдалося надіслати: ти не в каналі локації\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"نہیں بھیج سکتے: آپ لوکیشن چینل میں نہیں ہیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không gửi được: bạn không ở trong kênh vị trí\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"发送失败：你不在位置频道中\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"發送失敗：你不在位置頻道中\"\n          }\n        }\n      }\n    },\n    \"system.location.send_failed\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تعذر الإرسال إلى قناة الموقع\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"লোকেশন চ্যানেলে পাঠাতে ব্যর্থ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"konnte nicht an den standortkanal senden\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"failed to send to location channel\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no se pudo enviar al canal de ubicación\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nabigong magpadala sa channel ng lokasyon\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"envoi au canal localisation impossible\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"השליחה לערוץ המיקום נכשלה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"लोकेशन चैनल पर भेजना विफल\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gagal mengirim ke kanal lokasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"impossibile inviare al canale posizione\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ロケーションチャンネルに送信できませんでした\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"위치 채널로 전송에 실패했습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gagal menghantar ke kanal lokasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"स्थान च्यानलमा पठाउन सकेन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"versturen naar locatiekanaal mislukt\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wysłanie do kanału lokalizacji nie powiodło się\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"falha ao enviar para o canal de localização\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não foi possível enviar para o canal de localização\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не удалось отправить в канал локации\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kunde inte skicka till platskanalen\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இட சேனலுக்கு அனுப்புதல் தோல்வியடைந்தது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ส่งไปยังช่องตำแหน่งล้มเหลว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"konum kanalına gönderme başarısız\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"не вдалося надіслати в канал локації\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لوکیشن چینل کو بھیجنا ناکام رہا\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"gửi tới kênh vị trí thất bại\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"无法发送到位置频道\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"無法發送到位置頻道\"\n          }\n        }\n      }\n    },\n    \"system.tor.dev_bypass\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"بناء تطوير: تجاوز tor مفعل.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ডেভেলপমেন্ট বিল্ড: Tor বাইপাস সক্রিয়।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dev-build: tor-bypass aktiv.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"development build: Tor bypass enabled.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"compilación de desarrollo: bypass de Tor activado.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"developer build: naka-enable ang Tor bypass.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"build de développement : bypass tor actif.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"בנייה לפיתוח: עקיפת tor פעילה.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"डेवलपमेंट बिल्ड: Tor बायपास सक्षम।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"build pengembangan: bypass tor aktif.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"build di sviluppo: bypass tor attivo.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"開発ビルド: torバイパスが有効です。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"개발 빌드: Tor 우회가 활성화되었습니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"build pengembangan: bypass tor aktif.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"डेभ बिल्ड: tor बाइपास सक्षम।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ontwikkelaarsbuild: Tor-bypass ingeschakeld.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wersja deweloperska: obejście Tor włączone.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"compilação de desenvolvimento: bypass do Tor ativo.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"compilação de desenvolvimento: bypass de tor ativo.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dev-сборка: обход tor включён.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"utvecklarbuild: Tor-bypass aktiverad.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"மேம்பாட்டு கட்டிடம்: Tor பயாபாஸ் செயல்படுத்தப்பட்டது.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"รุ่นสำหรับนักพัฒนา: เปิดใช้งานการข้าม tor\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geliştirme yapısı: Tor atlatması etkin.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dev-збірка: обхід tor увімкнено.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ڈیولپر بلڈ: Tor بائی پاس فعال ہے۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bản dành cho dev: đang bật bỏ qua Tor.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"开发构建：tor 绕过已启用。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"開發版本：tor 繞過已啟用。\"\n          }\n        }\n      }\n    },\n    \"system.tor.restarted\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor أُعيد تشغيله. تمت استعادة التوجيه.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor পুনরায় চালু হয়েছে। নেটওয়ার্ক রাউটিং পুনরুদ্ধার হয়েছে।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor wurde neu gestartet. routing wiederhergestellt.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor restarted. network routing restored.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor se reinició. Se restauró el enrutamiento de la red.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nag-restart ang tor. naibalik ang routing ng network.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor a redémarré. routage restauré.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor הופעל מחדש. הניתוב שוחזר.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor पुनः आरंभ हो गया। नेटवर्क रूटिंग बहाल।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor dimulai ulang. perutean dipulihkan.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor è stato riavviato. instradamento ripristinato.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"torを再起動しました。ルーティングを復旧。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor가 다시 시작되었습니다. 네트워크 라우팅이 복원되었습니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor dimulai ulang. perutean dipulihkan.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor फेरि सुरु भयो। रूटिङ पुनःस्थापित।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor opnieuw gestart. netwerkrouting hersteld.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor uruchomiony ponownie. trasowanie przywrócone.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"O Tor foi reiniciado. O encaminhamento de rede foi restaurado.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor reiniciou. roteamento restaurado.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor перезапущен. маршрутизация восстановлена.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor har startats om. nätverksroutingen återställd.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor மீண்டும் தொடங்கியுள்ளது. நெட்வொர்க் வழிமுறை மீட்டெடுக்கப்பட்டது.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor เริ่มใหม่ เส้นทางเครือข่ายกลับมาแล้ว\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor yeniden başlatıldı. ağ yönlendirmesi geri geldi.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor перезапущено. маршрутизацію відновлено.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor نے دوبارہ آغاز کر دیا۔ نیٹ ورک روٹنگ بحال ہو گئی۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor đã khởi động lại. định tuyến mạng được khôi phục.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor 已重启。网络路由已恢复。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor 已重啟。網路路由已恢復。\"\n          }\n        }\n      }\n    },\n    \"system.tor.restarting\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor يعاد تشغيله لاستعادة الاتصال...\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor সংযোগ ফিরিয়ে আনতে পুনরায় চালু হচ্ছে...\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor startet neu, um die verbindung herzustellen...\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor restarting to recover connectivity...\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor se está reiniciando para recuperar la conectividad...\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nagre-restart ang tor upang ibalik ang koneksyon...\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor redémarre pour rétablir la connectivité...\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor מופעל מחדש להשבת החיבור...\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"कनेक्टिविटी बहाल करने के लिए Tor पुनः आरंभ हो रहा है...\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor sedang dimulai ulang untuk memulihkan konektivitas...\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor si sta riavviando per ripristinare la connettività...\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"接続回復のためtorを再起動しています...\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"연결 복구를 위해 tor를 다시 시작하는 중...\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor sedang dimulai ulang untuk memulihkan konektivitas...\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor जडान फर्काउन पुनः सुरु हुँदैछ...\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor wordt opnieuw gestart om verbinding te herstellen...\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor uruchamia się ponownie, aby przywrócić łączność...\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"O Tor está a reiniciar para recuperar a conectividade...\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor está reiniciando para recuperar conectividade...\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor перезапускается, чтобы вернуть связь...\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor startas om för att återställa anslutningen...\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"இணைப்பை மீட்டெடுக்க tor மீண்டும் தொடங்குகிறது...\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor กำลังเริ่มใหม่เพื่อกู้คืนการเชื่อมต่อ...\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor bağlantıyı kurtarmak için yeniden başlatılıyor...\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor перезапускається, щоб відновити підключення...\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor کنکشن بحال کرنے کیلئے دوبارہ شروع ہو رہا ہے...\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor đang khởi động lại để phục hồi kết nối...\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor 正在重启以恢复连接...\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor 正在重啟以恢復連線...\"\n          }\n        }\n      }\n    },\n    \"system.tor.started\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor يعمل. كل الدردشة تمر عبر tor للخصوصية.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor চালু হয়েছে। IP গোপন রাখতে সব চ্যাট Tor দিয়ে যাচ্ছে।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor läuft. der gesamte chat wird über tor geleitet.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor started. routing all chats via tor for IP privacy.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor se inició. Todo el chat se enruta por Tor para privacidad.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nagsimula ang tor. lahat ng chat ay dumadaan sa tor para itago ang IP.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor a démarré. tout le chat passe par tor pour la confidentialité.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor פעיל. כל הצ'אט עובר דרך tor לפרטיות.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor शुरू हो गया। IP गोपनीयता के लिए सभी चैट Tor से रूट हो रहे हैं।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor berjalan. seluruh chat dirutekan lewat tor demi privasi.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor è avviato. tutta la chat passa da tor per la privacy.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"torを起動しました。全チャットをtor経由で配信します。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor가 시작되었습니다. IP 보호를 위해 모든 대화를 tor를 통해 라우팅합니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor berjalan. seluruh chat dirutekan lewat tor demi privasi.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor सुरु भयो। गोपनीयताका लागि पूरा च्याट tor मार्फत जान्छ।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor gestart. alle chats gaan via tor voor IP-privacy.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor uruchomiony. wszystkie czaty idą przez tor dla prywatności IP.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"O Tor foi iniciado. Todas as conversas são encaminhadas via Tor para ocultar o IP.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor iniciou. todo o chat é roteado por tor para privacidade.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor запущен. весь чат идёт через tor для приватности.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor startad. all chatt routas via tor för IP-integritet.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor தொடங்கியுள்ளது. IP பாதுகாப்பிற்காக அனைத்து உரையாடலும் tor வழியாக செல்கிறது.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor เริ่มทำงานแล้ว ส่งแชททั้งหมดผ่าน tor เพื่อปกปิด IP\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor başlatıldı. IP gizliliği için tüm sohbetler Tor üzerinden yönlendiriliyor.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor запущено. увесь чат іде через tor для приватності.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor شروع ہو گیا۔ IP راز داری کیلئے تمام چیٹس tor سے گزرتی ہیں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor đã khởi động. toàn bộ chat đi qua tor để ẩn IP.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor 已启动。所有聊天通过 tor 路由以保护 IP。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor 已啟動。所有聊天透過 tor 路由以保護 IP。\"\n          }\n        }\n      }\n    },\n    \"system.tor.starting\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"يتم تشغيل tor...\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor চালু হচ্ছে...\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor wird gestartet...\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"starting tor...\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"iniciando Tor...\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"sinisimulan ang tor...\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"lancement de tor...\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor מופעל...\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor शुरू हो रहा है...\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menjalankan tor...\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"avvio tor...\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"torを起動中...\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor 시작 중...\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"menjalankan tor...\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor सुरु हुँदै...\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor wordt gestart...\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"uruchamianie tor...\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"O Tor está a iniciar...\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"iniciando tor...\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"запуск tor...\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor startar...\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor தொடங்குகிறது...\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"กำลังเริ่ม tor...\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Tor başlatılıyor...\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"запуск tor...\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tor شروع ہو رہا ہے...\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đang khởi động tor...\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"正在启动 tor...\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"正在啟動 tor...\"\n          }\n        }\n      }\n    },\n    \"verification.my_qr.accessibility_label\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"رمز qr للتحقق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"যাচাই QR কোড\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifizierungs-qr-code\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verification QR code\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"código QR de verificación\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR code para sa beripikasyon\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"code qr de vérification\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"קוד qr לאימות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सत्यापन QR कोड\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kode qr verifikasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"codice qr di verifica\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"検証用qrコード\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"인증 QR 코드\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kode qr verifikasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रमाणीकरण qr कोड\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR-code voor verificatie\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kod QR do weryfikacji\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"código QR de verificação\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"código qr de verificação\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr-код проверки\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR-kod för verifiering\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சரிபார்ப்பு QR குறியீடு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"รหัส QR สำหรับยืนยัน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"doğrulama QR kodu\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr-код підтвердження\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تصدیقی QR کوڈ\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"mã QR xác minh\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"验证 QR 码\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"驗證 QR 碼\"\n          }\n        }\n      }\n    },\n    \"verification.my_qr.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"امسح للتحقق مني\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"আমাকে যাচাই করতে স্ক্যান করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"scanne, um mich zu verifizieren\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"scan to verify me\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"escanea para verificarme\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-scan para i-verify ako\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"scanne pour me vérifier\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"סרוק כדי לאמת\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मुझे सत्यापित करने के लिए स्कैन करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pindai untuk verifikasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"scansiona per verificarmi\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"スキャンして確認\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"스캔해서 인증하세요\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pindai untuk verifikasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मलाई प्रमाणित गर्न स्क्यान गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"scan om mij te verifiëren\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zeskanuj, aby mnie zweryfikować\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Digitaliza para me verificares\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"escaneie para me verificar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"отсканируй, чтобы подтвердить меня\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"skanna för att verifiera mig\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"என்னைச் சரிபார்க்க ஸ்கேன் செய்யவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"สแกนเพื่อยืนยันตัวฉัน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"beni doğrulamak için tara\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"скануй, щоб підтвердити мене\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"مجھے تصدیق کرنے کیلئے اسکین کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"quét để xác minh tôi\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"扫描验证我\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"掃描驗證我\"\n          }\n        }\n      }\n    },\n    \"verification.my_qr.unavailable\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr غير متاح\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR অনুপলব্ধ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr nicht verfügbar\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR unavailable\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR no disponible\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"walang QR\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr indisponible\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr לא זמין\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR उपलब्ध नहीं\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr tidak tersedia\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr non disponibile\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qrは利用不可\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR 사용 불가\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr tidak tersedia\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr उपलब्ध छैन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR niet beschikbaar\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR niedostępny\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR indisponível\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr indisponível\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr недоступен\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR ej tillgänglig\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR கிடைக்கவில்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่มี QR\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR mevcut değil\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr недоступний\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR دستیاب نہیں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR không khả dụng\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR 不可用\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR 不可用\"\n          }\n        }\n      }\n    },\n    \"verification.scan.paste_prompt\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"الصق محتوى qr للتحقق:\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"যাচাই করতে QR বিষয়বস্তু পেস্ট করুন:\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"füge den qr-inhalt zum prüfen ein:\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"paste QR content to validate:\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pega el contenido del QR para validarlo:\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-paste ang nilalaman ng QR para beripikahin:\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"colle le contenu du qr pour valider :\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הדבק תוכן qr לאימות:\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सत्यापित करने के लिए QR सामग्री पेस्ट करें:\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tempel konten qr untuk validasi:\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"incolla il contenuto del qr per convalidare:\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"確認するqr内容を貼り付け:\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"인증을 위해 QR 내용을 붙여넣으세요:\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tempel konten qr untuk validasi:\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रमाणित गर्न qr सामग्री पेस्ट गर:\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"plak QR-inhoud om te valideren:\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"wklej zawartość QR, aby zweryfikować:\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Cola o conteúdo do QR para validar:\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"cole o conteúdo do qr para validar:\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"вставь содержимое qr для проверки:\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"klistra in QR-innehåll för att validera:\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சரிபார்க்க QR உள்ளடக்கத்தை ஒட்டு:\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"วางเนื้อหา QR เพื่อยืนยัน:\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"doğrulamak için QR içeriğini yapıştırın:\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"встав вміст qr для перевірки:\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تصدیق کیلئے QR مواد چسپاں کریں:\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"dán nội dung QR để kiểm tra:\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"粘贴 QR 内容以验证：\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"粘貼 QR 內容以驗證：\"\n          }\n        }\n      }\n    },\n    \"verification.scan.prompt_friend\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"امسح qr لصديق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"বন্ধুর QR স্ক্যান করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"scanne den qr eines freundes\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"scan a friend's QR\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"escanea el QR de un amigo\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"i-scan ang QR ng kaibigan\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"scanne le qr d'un ami\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"סרוק qr של חבר\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मित्र का QR स्कैन करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pindai qr teman\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"scansiona il qr di un amico\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"友達のqrをスキャン\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"친구의 QR 스캔하기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"pindai qr teman\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"साथीको qr स्क्यान गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"scan de QR van een vriend\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zeskanuj QR znajomego\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Digitaliza o QR de um amigo\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"escaneie o qr de um amigo\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"отсканируй qr друга\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"skanna en väns QR\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"நண்பரின் QR ஐ ஸ்கேன் செய்யவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"สแกน QR ของเพื่อน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"bir arkadaşının QR'ını tara\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"скануй qr друга\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"دوست کا QR اسکین کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"quét QR của bạn\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"扫描好友的 QR\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"掃描好友的 QR\"\n          }\n        }\n      }\n    },\n    \"verification.scan.status.invalid\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr غير صالح أو منتهٍ\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"অবৈধ বা মেয়াদোত্তীর্ণ QR পেলোড\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr ungültig oder abgelaufen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"invalid or expired QR payload\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR inválido o caducado\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"di-wasto o paso na ang QR payload\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr invalide ou expiré\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr לא תקף או שפג תוקפו\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"अमान्य या समाप्त हुआ QR पेलोड\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr tidak valid atau kedaluwarsa\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr non valido o scaduto\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qrが無効または期限切れ\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR 페이로드가 잘못되었거나 만료되었습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr tidak valid atau kedaluwarsa\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr अवैध या म्याद सकिएको\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ongeldige of verlopen QR-payload\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nieprawidłowy lub wygasły payload QR\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"payload de QR inválido ou expirado\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr inválido ou expirado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr недействителен или просрочен\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ogiltig eller utgången QR-payload\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சரியானதல்ல அல்லது காலாவதியான QR payload\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"payload ของ QR ไม่ถูกต้องหรือหมดอายุ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geçersiz veya süresi dolmuş QR yükü\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"qr недійсний або прострочений\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"غلط یا میعاد ختم شدہ QR payload\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"payload QR không hợp lệ hoặc đã hết hạn\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR 无效或已过期\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"QR 無效或已過期\"\n          }\n        }\n      }\n    },\n    \"verification.scan.status.no_peer\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"لم يتم العثور على قرين مطابق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"মিলে যাওয়া পিয়ার খুঁজে পাওয়া যায়নি\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"kein passender peer gefunden\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"could not find matching peer\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"no se encontró un peer coincidente\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"walang nahanap na kaakibat na peer\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"aucun pair correspondant trouvé\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"לא נמצא עמית תואם\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मिलता-जुलता पीयर नहीं मिला\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak ada peer yang cocok\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nessun peer corrispondente trovato\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"該当するピアが見つかりません\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"일치하는 피어를 찾을 수 없습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"tidak ada peer yang cocok\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"मिल्ने peer फेला परेन\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"geen overeenkomende peer gevonden\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nie znaleziono dopasowanego peera\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"não foi possível encontrar o par correspondente\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"nenhum peer correspondente encontrado\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"соответствующий пир не найден\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"hittade ingen matchande peer\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"பொருந்தும் peer கிடைக்கவில்லை\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ไม่พบเพียร์ที่ตรงกัน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"eş bulunamadı\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"відповідний пір не знайдений\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ملتا جلتا peer نہیں ملا\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"không tìm thấy nút phù hợp\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未找到匹配的同伴\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"未找到匹配的同伴\"\n          }\n        }\n      }\n    },\n    \"verification.scan.status.requested\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تم طلب التحقق لـ %@\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@-এর জন্য যাচাই অনুরোধ করা হয়েছে\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifizierung für %@ angefordert\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verification requested for %@\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"se solicitó la verificación de %@\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"humiling ng beripikasyon para kay %@\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"vérification demandée pour %@\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"התבקש אימות עבור %@\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ के लिए सत्यापन अनुरोध किया गया\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifikasi diminta untuk %@\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifica richiesta per %@\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@の検証をリクエストしました\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@에 대한 인증이 요청되었습니다\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifikasi diminta untuk %@\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ को लागि प्रमाणीकरण अनुरोध भयो\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verificatie aangevraagd voor %@\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"poproszono o weryfikację %@\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verificação solicitada para %@\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verificação solicitada para %@\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"проверка запрошена для %@\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifiering begärd för %@\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ க்கான சரிபார்ப்பு கோரப்பட்டுள்ளது\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ร้องขอยืนยันสำหรับ %@\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ için doğrulama istendi\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"перевірка запитана для %@\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"%@ کیلئے تصدیق کی درخواست کی گئی\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"đã yêu cầu xác minh cho %@\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已请求 %@ 的验证\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"已請求 %@ 的驗證\"\n          }\n        }\n      }\n    },\n    \"verification.scan.validate\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تحقق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"যাচাই করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"prüfen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"validate\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"validar\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"beripikahin\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"valider\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אשר\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सत्यापित करें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"validasi\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"convalida\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"確認\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"인증하기\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"validasi\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रमाणित गर\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifiëren\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"zweryfikuj\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"validar\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"validar\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"проверить\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"verifiera\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சரிபார்க்க\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ยืนยัน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"doğrula\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"перевірити\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تصدیق کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"xác minh\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"验证\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"驗證\"\n          }\n        }\n      }\n    },\n    \"verification.sheet.title\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تحقق\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"যাচাই\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"VERIFIZIEREN\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"VERIFY\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"VERIFICAR\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"BERIPIKA\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"VÉRIFIER\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"אימות\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"सत्यापन\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"VERIFIKASI\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"VERIFICA\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"確認\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"인증\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"VERIFIKASI\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"प्रमाणित\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"VERIFICATIE\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"WERYFIKACJA\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"VERIFICAR\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"VERIFICAR\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ПРОВЕРИТЬ\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"VERIFIERA\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"சரிபார்ப்பு\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ยืนยัน\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"DOĞRULA\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ПЕРЕВІРИТИ\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تصدیق\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"XÁC MINH\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"验证\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"驗證\"\n          }\n        }\n      }\n    },\n    \"Voice notes are only available in mesh chats.\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"الملاحظات الصوتية متاحة فقط في محادثات الميش.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ভয়েস নোট শুধু মেশ চ্যাটে উপলব্ধ।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Sprachnachrichten sind nur im Mesh-Chat verfügbar.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Voice notes are only available in mesh chats.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Las notas de voz solo están disponibles en los chats de mesh.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Ang mga voice note ay available lamang sa mga mesh chat.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Les notes vocales sont uniquement disponibles dans les discussions mesh.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"הערות קוליות זמינות רק בצ׳אט של mesh.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"वॉइस नोट्स केवल मेश चैट में ही उपलब्ध हैं।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Catatan suara hanya tersedia di obrolan mesh.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Le note vocali sono disponibili solo nelle chat mesh.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ボイスメモはメッシュチャットでのみ利用できます。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"음성 메모는 메쉬 채팅에서만 사용할 수 있습니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Nota suara hanya tersedia dalam sembang mesh.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"भ्वाइस नोटहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Spraaknotities zijn alleen beschikbaar in mesh-chats.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Notatki głosowe są dostępne tylko na czatach mesh.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"As notas de voz só estão disponíveis nos chats mesh.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"As mensagens de voz só estão disponíveis nos chats mesh.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Голосовые сообщения доступны только в mesh-чатах.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Röstanteckningar är bara tillgängliga i mesh-chattar.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"குரல் குறிப்புகள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"บันทึกเสียงใช้งานได้เฉพาะในแชต mesh เท่านั้น\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Sesli notlar yalnızca mesh sohbetlerinde kullanılabilir.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Голосові нотатки доступні лише в mesh-чатах.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"وائس نوٹس صرف میش چیٹس میں دستیاب ہیں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Ghi chú giọng nói chỉ khả dụng trong các cuộc trò chuyện mesh.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"语音消息仅可在 mesh 聊天中使用。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"語音訊息僅能在 mesh 聊天中使用。\"\n          }\n        }\n      }\n    },\n    \"Images are only available in mesh chats.\" : {\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"الصور متاحة فقط في محادثات الميش.\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ছবি শুধু মেশ চ্যাটে উপলব্ধ।\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bilder sind nur im Mesh-Chat verfügbar.\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Images are only available in mesh chats.\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Las imágenes solo están disponibles en los chats de mesh.\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Ang mga larawan ay available lamang sa mga mesh chat.\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Les images sont uniquement disponibles dans les discussions mesh.\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"תמונות זמינות רק בצ׳אט של mesh.\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"चित्र केवल मेश चैट में ही उपलब्ध हैं।\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Gambar hanya tersedia di obrolan mesh.\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Le immagini sono disponibili solo nelle chat mesh.\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"画像はメッシュチャットでのみ利用できます。\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"이미지는 메쉬 채팅에서만 사용할 수 있습니다.\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Imej hanya tersedia dalam sembang mesh.\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Afbeeldingen zijn alleen beschikbaar in mesh-chats.\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Obrazy są dostępne tylko na czatach mesh.\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"As imagens só estão disponíveis nos chats mesh.\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"As imagens só estão disponíveis nos chats mesh.\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Изображения доступны только в mesh-чатах.\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bilder är bara tillgängliga i mesh-chattar.\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்.\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Görseller yalnızca mesh sohbetlerinde kullanılabilir.\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Зображення доступні лише в mesh-чатах.\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"تصاویر صرف میش چیٹس میں دستیاب ہیں۔\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh.\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"图片仅可在 mesh 聊天中使用。\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"圖片僅能在 mesh 聊天中使用。\"\n          }\n        }\n      }\n    },\n    \"Choose an image\" : {\n      \"comment\" : \"A label displayed above a button that allows the user to choose an image to send.\",\n      \"extractionState\" : \"manual\",\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"اختر صورة\"\n          }\n        },\n        \"bn\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"একটি ছবি নির্বাচন করুন\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bild auswählen\"\n          }\n        },\n        \"en\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Choose an image\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Elige una imagen\"\n          }\n        },\n        \"fil\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Pumili ng larawan\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Choisir une image\"\n          }\n        },\n        \"he\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"בחר תמונה\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एक चित्र चुनें\"\n          }\n        },\n        \"id\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Pilih gambar\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Scegli un’immagine\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"画像を選択\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"이미지를 선택하세요\"\n          }\n        },\n        \"ms\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Pilih imej\"\n          }\n        },\n        \"ne\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"एउटा तस्वीर चयन गर्नुहोस्\"\n          }\n        },\n        \"nl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Kies een afbeelding\"\n          }\n        },\n        \"pl\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Wybierz obraz\"\n          }\n        },\n        \"pt\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Escolher uma imagem\"\n          }\n        },\n        \"pt-BR\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Escolha uma imagem\"\n          }\n        },\n        \"ru\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Выберите изображение\"\n          }\n        },\n        \"sv\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Välj en bild\"\n          }\n        },\n        \"ta\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ஒரு படத்தைத் தேர்ந்தெடுக்கவும்\"\n          }\n        },\n        \"th\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"เลือกภาพ\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Bir görüntü seç\"\n          }\n        },\n        \"uk\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Виберіть зображення\"\n          }\n        },\n        \"ur\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ایک تصویر منتخب کریں\"\n          }\n        },\n        \"vi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Chọn một hình ảnh\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"选择图像\"\n          }\n        },\n        \"zh-Hant\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"選擇圖像\"\n          }\n        }\n      }\n    }\n  },\n  \"version\" : \"1.1\"\n}\n"
  },
  {
    "path": "bitchat/Models/BitchatMessage.swift",
    "content": "//\n// BitchatMessage.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\n/// Represents a user-visible message in the BitChat system.\n/// Handles both broadcast messages and private encrypted messages,\n/// with support for mentions, replies, and delivery tracking.\n/// - Note: This is the primary data model for chat messages\nfinal class BitchatMessage: Codable {\n    let id: String\n    let sender: String\n    let content: String\n    let timestamp: Date\n    let isRelay: Bool\n    let originalSender: String?\n    let isPrivate: Bool\n    let recipientNickname: String?\n    let senderPeerID: PeerID?\n    let mentions: [String]?  // Array of mentioned nicknames\n    var deliveryStatus: DeliveryStatus? // Delivery tracking\n    \n    // Cached formatted text (not included in Codable)\n    private var _cachedFormattedText: [String: AttributedString] = [:]\n    \n    func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {\n        return _cachedFormattedText[\"\\(isDark)-\\(isSelf)\"]\n    }\n    \n    func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {\n        _cachedFormattedText[\"\\(isDark)-\\(isSelf)\"] = text\n    }\n    \n    // Codable implementation\n    enum CodingKeys: String, CodingKey {\n        case id, sender, content, timestamp, isRelay, originalSender\n        case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus\n    }\n    \n    init(\n        id: String? = nil,\n        sender: String,\n        content: String,\n        timestamp: Date,\n        isRelay: Bool,\n        originalSender: String? = nil,\n        isPrivate: Bool = false,\n        recipientNickname: String? = nil,\n        senderPeerID: PeerID? = nil,\n        mentions: [String]? = nil,\n        deliveryStatus: DeliveryStatus? = nil\n    ) {\n        self.id = id ?? UUID().uuidString\n        self.sender = sender\n        self.content = content\n        self.timestamp = timestamp\n        self.isRelay = isRelay\n        self.originalSender = originalSender\n        self.isPrivate = isPrivate\n        self.recipientNickname = recipientNickname\n        self.senderPeerID = senderPeerID\n        self.mentions = mentions\n        self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)\n    }\n}\n\n// MARK: - Equatable Conformance\n\nextension BitchatMessage: Equatable {\n    static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {\n        return lhs.id == rhs.id &&\n               lhs.sender == rhs.sender &&\n               lhs.content == rhs.content &&\n               lhs.timestamp == rhs.timestamp &&\n               lhs.isRelay == rhs.isRelay &&\n               lhs.originalSender == rhs.originalSender &&\n               lhs.isPrivate == rhs.isPrivate &&\n               lhs.recipientNickname == rhs.recipientNickname &&\n               lhs.senderPeerID == rhs.senderPeerID &&\n               lhs.mentions == rhs.mentions &&\n               lhs.deliveryStatus == rhs.deliveryStatus\n    }\n}\n\n// MARK: - Binary encoding\n\nextension BitchatMessage {\n    func toBinaryPayload() -> Data? {\n        var data = Data()\n        \n        // Message format:\n        // - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)\n        // - Timestamp: 8 bytes (seconds since epoch)\n        // - ID length: 1 byte\n        // - ID: variable\n        // - Sender length: 1 byte\n        // - Sender: variable\n        // - Content length: 2 bytes\n        // - Content: variable\n        // Optional fields based on flags:\n        // - Original sender length + data\n        // - Recipient nickname length + data\n        // - Sender peer ID length + data\n        // - Mentions array\n        \n        var flags: UInt8 = 0\n        if isRelay { flags |= 0x01 }\n        if isPrivate { flags |= 0x02 }\n        if originalSender != nil { flags |= 0x04 }\n        if recipientNickname != nil { flags |= 0x08 }\n        if senderPeerID != nil { flags |= 0x10 }\n        if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }\n        \n        data.append(flags)\n        \n        // Timestamp (in milliseconds)\n        let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)\n        // Encode as 8 bytes, big-endian\n        for i in (0..<8).reversed() {\n            data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))\n        }\n        \n        // ID\n        if let idData = id.data(using: .utf8) {\n            data.append(UInt8(min(idData.count, 255)))\n            data.append(idData.prefix(255))\n        } else {\n            data.append(0)\n        }\n        \n        // Sender\n        if let senderData = sender.data(using: .utf8) {\n            data.append(UInt8(min(senderData.count, 255)))\n            data.append(senderData.prefix(255))\n        } else {\n            data.append(0)\n        }\n        \n        // Content\n        if let contentData = content.data(using: .utf8) {\n            let length = UInt16(min(contentData.count, 65535))\n            // Encode length as 2 bytes, big-endian\n            data.append(UInt8((length >> 8) & 0xFF))\n            data.append(UInt8(length & 0xFF))\n            data.append(contentData.prefix(Int(length)))\n        } else {\n            data.append(contentsOf: [0, 0])\n        }\n        \n        // Optional fields\n        if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {\n            data.append(UInt8(min(origData.count, 255)))\n            data.append(origData.prefix(255))\n        }\n        \n        if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {\n            data.append(UInt8(min(recipData.count, 255)))\n            data.append(recipData.prefix(255))\n        }\n        \n        if let peerData = senderPeerID?.id.data(using: .utf8) {\n            data.append(UInt8(min(peerData.count, 255)))\n            data.append(peerData.prefix(255))\n        }\n        \n        // Mentions array\n        if let mentions = mentions {\n            data.append(UInt8(min(mentions.count, 255))) // Number of mentions\n            for mention in mentions.prefix(255) {\n                if let mentionData = mention.data(using: .utf8) {\n                    data.append(UInt8(min(mentionData.count, 255)))\n                    data.append(mentionData.prefix(255))\n                } else {\n                    data.append(0)\n                }\n            }\n        }\n        \n        \n        return data\n    }\n    \n    convenience init?(_ data: Data) {\n        // Create an immutable copy to prevent threading issues\n        let dataCopy = Data(data)\n        \n        \n        guard dataCopy.count >= 13 else {\n            return nil\n        }\n        \n        var offset = 0\n        \n        // Flags\n        guard offset < dataCopy.count else {\n            return nil\n        }\n        let flags = dataCopy[offset]; offset += 1\n        let isRelay = (flags & 0x01) != 0\n        let isPrivate = (flags & 0x02) != 0\n        let hasOriginalSender = (flags & 0x04) != 0\n        let hasRecipientNickname = (flags & 0x08) != 0\n        let hasSenderPeerID = (flags & 0x10) != 0\n        let hasMentions = (flags & 0x20) != 0\n        \n        // Timestamp\n        guard offset + 8 <= dataCopy.count else {\n            return nil\n        }\n        let timestampData = dataCopy[offset..<offset+8]\n        let timestampMillis = timestampData.reduce(0) { result, byte in\n            (result << 8) | UInt64(byte)\n        }\n        offset += 8\n        let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)\n        \n        // ID\n        guard offset < dataCopy.count else {\n            return nil\n        }\n        let idLength = Int(dataCopy[offset]); offset += 1\n        guard offset + idLength <= dataCopy.count else {\n            return nil\n        }\n        let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString\n        offset += idLength\n        \n        // Sender\n        guard offset < dataCopy.count else {\n            return nil\n        }\n        let senderLength = Int(dataCopy[offset]); offset += 1\n        guard offset + senderLength <= dataCopy.count else {\n            return nil\n        }\n        let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? \"unknown\"\n        offset += senderLength\n        \n        // Content\n        guard offset + 2 <= dataCopy.count else {\n            return nil\n        }\n        let contentLengthData = dataCopy[offset..<offset+2]\n        let contentLength = Int(contentLengthData.reduce(0) { result, byte in\n            (result << 8) | UInt16(byte)\n        })\n        offset += 2\n        guard offset + contentLength <= dataCopy.count else {\n            return nil\n        }\n        \n        let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? \"\"\n        offset += contentLength\n        \n        // Optional fields\n        var originalSender: String?\n        if hasOriginalSender && offset < dataCopy.count {\n            let length = Int(dataCopy[offset]); offset += 1\n            if offset + length <= dataCopy.count {\n                originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)\n                offset += length\n            }\n        }\n        \n        var recipientNickname: String?\n        if hasRecipientNickname && offset < dataCopy.count {\n            let length = Int(dataCopy[offset]); offset += 1\n            if offset + length <= dataCopy.count {\n                recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)\n                offset += length\n            }\n        }\n        \n        var senderPeerID: PeerID?\n        if hasSenderPeerID && offset < dataCopy.count {\n            let length = Int(dataCopy[offset]); offset += 1\n            if offset + length <= dataCopy.count {\n                senderPeerID = PeerID(data: dataCopy[offset..<offset+length])\n                offset += length\n            }\n        }\n        \n        // Mentions array\n        var mentions: [String]?\n        if hasMentions && offset < dataCopy.count {\n            let mentionCount = Int(dataCopy[offset]); offset += 1\n            if mentionCount > 0 {\n                mentions = []\n                for _ in 0..<mentionCount {\n                    if offset < dataCopy.count {\n                        let length = Int(dataCopy[offset]); offset += 1\n                        if offset + length <= dataCopy.count {\n                            if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {\n                                mentions?.append(mention)\n                            }\n                            offset += length\n                        }\n                    }\n                }\n            }\n        }\n        \n        self.init(\n            id: id,\n            sender: sender,\n            content: content,\n            timestamp: timestamp,\n            isRelay: isRelay,\n            originalSender: originalSender,\n            isPrivate: isPrivate,\n            recipientNickname: recipientNickname,\n            senderPeerID: senderPeerID,\n            mentions: mentions\n        )\n    }\n}\n\n// MARK: - Helpers\n\nextension BitchatMessage {\n    \n    private static let timestampFormatter: DateFormatter = {\n        let formatter = DateFormatter()\n        formatter.dateFormat = \"HH:mm:ss\"\n        return formatter\n    }()\n    \n    var formattedTimestamp: String {\n        Self.timestampFormatter.string(from: timestamp)\n    }\n}\n\nextension Array where Element == BitchatMessage {\n    /// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)\n    func cleanedAndDeduped() -> [Element] {\n        let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }\n        guard arr.count > 1 else {\n            return arr\n        }\n        var seen = Set<String>()\n        var dedup: [BitchatMessage] = []\n        for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {\n            if !seen.contains(m.id) {\n                dedup.append(m)\n                seen.insert(m.id)\n            }\n        }\n        return dedup\n    }\n}\n"
  },
  {
    "path": "bitchat/Models/BitchatPacket.swift",
    "content": "//\n// BitchatPacket.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\n/// The core packet structure for all BitChat protocol messages.\n/// Encapsulates all data needed for routing through the mesh network,\n/// including TTL for hop limiting and optional encryption.\n/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented\nstruct BitchatPacket: Codable {\n    let version: UInt8\n    let type: UInt8\n    let senderID: Data\n    let recipientID: Data?\n    let timestamp: UInt64\n    let payload: Data\n    var signature: Data?\n    var ttl: UInt8\n    var route: [Data]?\n    var isRSR: Bool\n    \n    init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {\n        self.version = version\n        self.type = type\n        self.senderID = senderID\n        self.recipientID = recipientID\n        self.timestamp = timestamp\n        self.payload = payload\n        self.signature = signature\n        self.ttl = ttl\n        self.route = route\n        self.isRSR = isRSR\n    }\n    \n    // Convenience initializer for new binary format\n    init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) {\n        self.version = 1\n        self.type = type\n        // Convert hex string peer ID to binary data (8 bytes)\n        var senderData = Data()\n        var tempID = senderID.id\n        while tempID.count >= 2 {\n            let hexByte = String(tempID.prefix(2))\n            if let byte = UInt8(hexByte, radix: 16) {\n                senderData.append(byte)\n            }\n            tempID = String(tempID.dropFirst(2))\n        }\n        self.senderID = senderData\n        self.recipientID = nil\n        self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds\n        self.payload = payload\n        self.signature = nil\n        self.ttl = ttl\n        self.route = nil\n        self.isRSR = isRSR\n    }\n    \n    var data: Data? {\n        BinaryProtocol.encode(self)\n    }\n    \n    func toBinaryData(padding: Bool = true) -> Data? {\n        BinaryProtocol.encode(self, padding: padding)\n    }\n\n    // Backward-compatible helper (defaults to padded encoding)\n    func toBinaryData() -> Data? {\n        toBinaryData(padding: true)\n    }\n    \n    /// Create binary representation for signing (without signature and TTL fields)\n    /// TTL is excluded because it changes during packet relay operations\n    func toBinaryDataForSigning() -> Data? {\n        // Create a copy without signature and with fixed TTL for signing\n        // TTL must be excluded because it changes during relay\n        let unsignedPacket = BitchatPacket(\n            type: type,\n            senderID: senderID,\n            recipientID: recipientID,\n            timestamp: timestamp,\n            payload: payload,\n            signature: nil, // Remove signature for signing\n            ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility\n            version: version,\n            route: route,\n            isRSR: false // RSR flag is mutable and not part of the signature\n        )\n        return BinaryProtocol.encode(unsignedPacket)\n    }\n    \n    static func from(_ data: Data) -> BitchatPacket? {\n        BinaryProtocol.decode(data)\n    }\n}\n"
  },
  {
    "path": "bitchat/Models/BitchatPeer.swift",
    "content": "import Foundation\nimport CoreBluetooth\n\n/// Represents a peer in the BitChat network with all associated metadata\nstruct BitchatPeer: Equatable {\n    let peerID: PeerID // Hex-encoded peer ID\n    let noisePublicKey: Data\n    let nickname: String\n    let lastSeen: Date\n    let isConnected: Bool\n    let isReachable: Bool\n    \n    // Favorite-related properties\n    var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?\n    \n    // Nostr identity (if known)\n    var nostrPublicKey: String?\n    \n    // Connection state\n    enum ConnectionState {\n        case bluetoothConnected\n        case meshReachable      // Seen via mesh recently, not directly connected\n        case nostrAvailable     // Mutual favorite, reachable via Nostr\n        case offline            // Not connected via any transport\n    }\n    \n    var connectionState: ConnectionState {\n        if isConnected {\n            return .bluetoothConnected\n        } else if isReachable {\n            return .meshReachable\n        } else if favoriteStatus?.isMutual == true {\n            // Mutual favorites can communicate via Nostr when offline\n            return .nostrAvailable\n        } else {\n            return .offline\n        }\n    }\n    \n    var isFavorite: Bool {\n        favoriteStatus?.isFavorite ?? false\n    }\n    \n    var isMutualFavorite: Bool {\n        favoriteStatus?.isMutual ?? false\n    }\n    \n    var theyFavoritedUs: Bool {\n        favoriteStatus?.theyFavoritedUs ?? false\n    }\n    \n    // Display helpers\n    var displayName: String {\n        nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname\n    }\n    \n    var statusIcon: String {\n        switch connectionState {\n        case .bluetoothConnected:\n            return \"📻\" // Radio icon for mesh connection\n        case .meshReachable:\n            return \"📡\" // Antenna for mesh reachable\n        case .nostrAvailable:\n            return \"🌐\" // Purple globe for Nostr\n        case .offline:\n            if theyFavoritedUs && !isFavorite {\n                return \"🌙\" // Crescent moon - they favorited us but we didn't reciprocate\n            } else {\n                return \"\"\n            }\n        }\n    }\n    \n    // Initialize from mesh service data\n    init(\n        peerID: PeerID,\n        noisePublicKey: Data,\n        nickname: String,\n        lastSeen: Date = Date(),\n        isConnected: Bool = false,\n        isReachable: Bool = false\n    ) {\n        self.peerID = peerID\n        self.noisePublicKey = noisePublicKey\n        self.nickname = nickname\n        self.lastSeen = lastSeen\n        self.isConnected = isConnected\n        self.isReachable = isReachable\n        \n        // Load favorite status - will be set later by the manager\n        self.favoriteStatus = nil\n        self.nostrPublicKey = nil\n    }\n    \n    static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {\n        lhs.peerID == rhs.peerID\n    }\n}\n"
  },
  {
    "path": "bitchat/Models/CommandInfo.swift",
    "content": "//\n// CommandsInfo.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\n// MARK: - CommandInfo Enum\n\nenum CommandInfo: String, Identifiable {\n    case block\n    case clear\n    case hug\n    case message = \"dm\"\n    case slap\n    case unblock\n    case who\n    case favorite\n    case unfavorite\n    \n    var id: String { rawValue }\n    \n    var alias: String { \"/\" + rawValue }\n    \n    var placeholder: String? {\n        switch self {\n        case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:\n            return \"<\" + String(localized: \"content.input.nickname_placeholder\") + \">\"\n        case .clear, .who:\n            return nil\n        }\n    }\n    \n    var description: String {\n        switch self {\n        case .block:        String(localized: \"content.commands.block\")\n        case .clear:        String(localized: \"content.commands.clear\")\n        case .hug:          String(localized: \"content.commands.hug\")\n        case .message:      String(localized: \"content.commands.message\")\n        case .slap:         String(localized: \"content.commands.slap\")\n        case .unblock:      String(localized: \"content.commands.unblock\")\n        case .who:          String(localized: \"content.commands.who\")\n        case .favorite:     String(localized: \"content.commands.favorite\")\n        case .unfavorite:   String(localized: \"content.commands.unfavorite\")\n        }\n    }\n    \n    static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {\n        let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]\n        if isGeoPublic || isGeoDM {\n            return baseCommands + [.favorite, .unfavorite]\n        }\n        return baseCommands\n    }\n}\n"
  },
  {
    "path": "bitchat/Models/MessagePadding.swift",
    "content": "//\n// MessagePadding.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\n/// Provides privacy-preserving message padding to obscure actual content length.\n/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.\nstruct MessagePadding {\n    // Standard block sizes for padding\n    static let blockSizes = [256, 512, 1024, 2048]\n    \n    // Add PKCS#7-style padding to reach target size\n    static func pad(_ data: Data, toSize targetSize: Int) -> Data {\n        guard data.count < targetSize else { return data }\n        \n        let paddingNeeded = targetSize - data.count\n        // Constrain to 255 to fit a single-byte pad length marker\n        guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }\n        \n        var padded = data\n        // PKCS#7: All pad bytes are equal to the pad length\n        padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))\n        return padded\n    }\n    \n    // Remove padding from data\n    static func unpad(_ data: Data) -> Data {\n        guard !data.isEmpty else { return data }\n        let last = data.last!\n        let paddingLength = Int(last)\n        // Must have at least 1 pad byte and not exceed data length\n        guard paddingLength > 0 && paddingLength <= data.count else { return data }\n        // Verify PKCS#7: all last N bytes equal to pad length\n        let start = data.count - paddingLength\n        let tail = data[start...]\n        for b in tail { if b != last { return data } }\n        return Data(data[..<start])\n    }\n    \n    // Find optimal block size for data\n    static func optimalBlockSize(for dataSize: Int) -> Int {\n        // Account for encryption overhead (~16 bytes for AES-GCM tag)\n        let totalSize = dataSize + 16\n        \n        // Find smallest block that fits\n        for blockSize in blockSizes {\n            if totalSize <= blockSize {\n                return blockSize\n            }\n        }\n        \n        // For very large messages, just use the original size\n        // (will be fragmented anyway)\n        return dataSize\n    }\n}\n"
  },
  {
    "path": "bitchat/Models/NoisePayload.swift",
    "content": "//\n// NoisePayload.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\n/// Helper to create typed Noise payloads\nstruct NoisePayload {\n    let type: NoisePayloadType\n    let data: Data\n    \n    /// Encode payload with type prefix\n    func encode() -> Data {\n        var encoded = Data()\n        encoded.append(type.rawValue)\n        encoded.append(data)\n        return encoded\n    }\n    \n    /// Decode payload from data\n    static func decode(_ data: Data) -> NoisePayload? {\n        // Ensure we have at least 1 byte for the type\n        guard !data.isEmpty else {\n            return nil\n        }\n        \n        // Safely get the first byte\n        let firstByte = data[data.startIndex]\n        guard let type = NoisePayloadType(rawValue: firstByte) else {\n            return nil\n        }\n        \n        // Create a proper Data copy (not a subsequence) for thread safety\n        let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()\n        return NoisePayload(type: type, data: payloadData)\n    }\n}\n"
  },
  {
    "path": "bitchat/Models/PeerID.swift",
    "content": "//\n// PeerID.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\nstruct PeerID: Equatable, Hashable {\n    enum Prefix: String, CaseIterable {\n        /// When no prefix is provided\n        case empty = \"\"\n        /// `\"mesh:\"`\n        case mesh = \"mesh:\"\n        /// `\"name:\"`\n        case name = \"name:\"\n        /// `\"noise:\"` (+ 64 characters hex)\n        case noise = \"noise:\"\n        /// `\"nostr_\"` (+ 16 characters hex)\n        case geoDM = \"nostr_\"\n        /// `\"nostr:\"` (+ 8 characters hex)\n        case geoChat = \"nostr:\"\n    }\n    \n    let prefix: Prefix\n    \n    /// Returns the actual value without any prefix\n    let bare: String\n    \n    /// Returns the full `id` value by combining `(prefix + bare)`\n    var id: String { prefix.rawValue + bare }\n    \n    // Private so the callers have to go through a convenience init\n    private init(prefix: Prefix, bare: any StringProtocol) {\n        self.prefix = prefix\n        self.bare = String(bare).lowercased()\n    }\n}\n\n// MARK: - Convenience Inits\n\nextension PeerID {\n    /// Convenience init to create GeoDM PeerID by appending `\"nostr_\"` to the first 16 characters of `pubKey`\n    init(nostr_ pubKey: String) {\n        self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))\n    }\n    \n    /// Convenience init to create GeoChat PeerID by appending `\"nostr:\"` to the first 8 characters of `pubKey`\n    init(nostr pubKey: String) {\n        self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))\n    }\n    \n    /// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts\n    init(str: any StringProtocol) {\n        if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {\n            self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count))\n        } else {\n            self.init(prefix: .empty, bare: str)\n        }\n    }\n    \n    /// Convenience init to handle `Optional<String>`\n    init?(str: (any StringProtocol)?) {\n        guard let str else { return nil }\n        self.init(str: str)\n    }\n    \n    /// Convenience init to create PeerID by converting Data to String\n    init?(data: Data) {\n        self.init(str: String(data: data, encoding: .utf8))\n    }\n    \n    /// Convenience init to \"hide\" hex-encoding implementation detail\n    init(hexData: Data) {\n        self.init(str: hexData.hexEncodedString())\n    }\n    \n    /// Convenience init to \"hide\" hex-encoding implementation detail\n    init?(hexData: Data?) {\n        guard let hexData else { return nil }\n        self.init(hexData: hexData)\n    }\n}\n\n// MARK: - Noise Public Key Helpers\n\nextension PeerID {\n    /// Derive the stable 16-hex peer ID from a Noise static public key\n    init(publicKey: Data) {\n        self.init(str: publicKey.sha256Fingerprint().prefix(16))\n    }\n    \n    /// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed\n    func toShort() -> PeerID {\n        if let noiseKey {\n            return PeerID(publicKey: noiseKey)\n        }\n        return self\n    }\n}\n\n// MARK: - Codable\n\nextension PeerID: Codable {\n    init(from decoder: any Decoder) throws {\n        self.init(str: try decoder.singleValueContainer().decode(String.self))\n    }\n    \n    func encode(to encoder: any Encoder) throws {\n        var container = encoder.singleValueContainer()\n        try container.encode(id)\n    }\n}\n\n// MARK: - Helpers\n\nextension PeerID {\n    var isEmpty: Bool {\n        id.isEmpty\n    }\n    \n    /// Returns true if `id` starts with \"`nostr:`\"\n    var isGeoChat: Bool {\n        prefix == .geoChat\n    }\n    \n    /// Returns true if `id` starts with \"`nostr_`\"\n    var isGeoDM: Bool {\n        prefix == .geoDM\n    }\n    \n    func toPercentEncoded() -> String {\n        id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id\n    }\n}\n\nextension PeerID {\n    var routingData: Data? {\n        if let direct = Data(hexString: id), direct.count == 8 { return direct }\n        if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }\n        let short = toShort()\n        return Data(hexString: short.id)\n    }\n\n    init?(routingData: Data) {\n        guard routingData.count == 8 else { return nil }\n        self.init(hexData: routingData)\n    }\n}\n\n// MARK: - Validation\n\nextension PeerID {\n    private enum Constants {\n        static let maxIDLength = 64\n        static let hexIDLength = 16 // 8 bytes = 16 hex chars\n    }\n    \n    /// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)\n    var isValid: Bool {\n        if prefix != .empty {\n            return PeerID(str: bare).isValid\n        }\n        \n        // Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)\n        if isShort || isNoiseKeyHex {\n            return true\n        }\n        \n        // If length equals short or full but isn't valid hex, reject\n        if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {\n            return false\n        }\n        \n        // Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)\n        let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: \"-_\"))\n        return !id.isEmpty &&\n                id.count < Constants.maxIDLength &&\n                id.rangeOfCharacter(from: validCharset.inverted) == nil\n    }\n    \n    /// Returns true if the `bare` id is all hex\n    var isHex: Bool {\n        bare.allSatisfy { $0.isHexDigit }\n    }\n    \n    /// Short routing IDs (exact 16-hex)\n    var isShort: Bool {\n        bare.count == Constants.hexIDLength && isHex\n    }\n    \n    /// Full Noise key hex (exact 64-hex)\n    var isNoiseKeyHex: Bool {\n        noiseKey != nil\n    }\n    \n    /// Full Noise key (exact 64-hex) as Data\n    var noiseKey: Data? {\n        guard bare.count == Constants.maxIDLength else { return nil }\n        return Data(hexString: bare)\n    }\n}\n\n// MARK: - Comparable\n\nextension PeerID: Comparable {\n    static func < (lhs: PeerID, rhs: PeerID) -> Bool {\n        lhs.id < rhs.id\n    }\n}\n\n// MARK: - CustomStringConvertible\n\nextension PeerID: CustomStringConvertible {\n    /// So it returns the actual `id` like before even inside another String\n    var description: String {\n        id\n    }\n}\n"
  },
  {
    "path": "bitchat/Models/ReadReceipt.swift",
    "content": "//\n// ReadReceipt.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\nstruct ReadReceipt: Codable {\n    let originalMessageID: String\n    let receiptID: String\n    var readerID: PeerID  // Who read it\n    let readerNickname: String\n    let timestamp: Date\n    \n    init(originalMessageID: String, readerID: PeerID, readerNickname: String) {\n        self.originalMessageID = originalMessageID\n        self.receiptID = UUID().uuidString\n        self.readerID = readerID\n        self.readerNickname = readerNickname\n        self.timestamp = Date()\n    }\n    \n    // For binary decoding\n    private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {\n        self.originalMessageID = originalMessageID\n        self.receiptID = receiptID\n        self.readerID = readerID\n        self.readerNickname = readerNickname\n        self.timestamp = timestamp\n    }\n    \n    func encode() -> Data? {\n        try? JSONEncoder().encode(self)\n    }\n    \n    static func decode(from data: Data) -> ReadReceipt? {\n        try? JSONDecoder().decode(ReadReceipt.self, from: data)\n    }\n    \n    // MARK: - Binary Encoding\n    \n    func toBinaryData() -> Data {\n        var data = Data()\n        data.appendUUID(originalMessageID)\n        data.appendUUID(receiptID)\n        // ReaderID as 8-byte hex string\n        var readerData = Data()\n        var tempID = readerID.id\n        while tempID.count >= 2 && readerData.count < 8 {\n            let hexByte = String(tempID.prefix(2))\n            if let byte = UInt8(hexByte, radix: 16) {\n                readerData.append(byte)\n            }\n            tempID = String(tempID.dropFirst(2))\n        }\n        while readerData.count < 8 {\n            readerData.append(0)\n        }\n        data.append(readerData)\n        data.appendDate(timestamp)\n        data.appendString(readerNickname)\n        return data\n    }\n    \n    static func fromBinaryData(_ data: Data) -> ReadReceipt? {\n        // Create defensive copy\n        let dataCopy = Data(data)\n        \n        // Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname\n        guard dataCopy.count >= 49 else { return nil }\n        \n        var offset = 0\n        \n        guard let originalMessageID = dataCopy.readUUID(at: &offset),\n              let receiptID = dataCopy.readUUID(at: &offset) else { return nil }\n        \n        guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }\n        let readerID = PeerID(hexData: readerIDData)\n        guard readerID.isValid else { return nil }\n        \n        guard let timestamp = dataCopy.readDate(at: &offset),\n              InputValidator.validateTimestamp(timestamp),\n              let readerNicknameRaw = dataCopy.readString(at: &offset),\n              let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }\n        \n        return ReadReceipt(originalMessageID: originalMessageID,\n                          receiptID: receiptID,\n                          readerID: readerID,\n                          readerNickname: readerNickname,\n                          timestamp: timestamp)\n    }\n}\n"
  },
  {
    "path": "bitchat/Models/RequestSyncPacket.swift",
    "content": "import Foundation\n\n// REQUEST_SYNC payload TLV (type, length16, value)\n//  - 0x01: P (uint8) — Golomb-Rice parameter\n//  - 0x02: M (uint32, big-endian) — hash range (N * 2^P)\n//  - 0x03: data (opaque) — GR bitstream bytes (MSB-first)\nstruct RequestSyncPacket {\n    let p: Int\n    let m: UInt32\n    let data: Data\n    let types: SyncTypeFlags?\n    let sinceTimestamp: UInt64?\n    let fragmentIdFilter: String?\n\n    init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {\n        self.p = p\n        self.m = m\n        self.data = data\n        self.types = types\n        self.sinceTimestamp = sinceTimestamp\n        self.fragmentIdFilter = fragmentIdFilter\n    }\n\n    func encode() -> Data {\n        var out = Data()\n        func putTLV(_ t: UInt8, _ v: Data) {\n            out.append(t)\n            let len = UInt16(v.count)\n            out.append(UInt8((len >> 8) & 0xFF))\n            out.append(UInt8(len & 0xFF))\n            out.append(v)\n        }\n        // P\n        putTLV(0x01, Data([UInt8(p & 0xFF)]))\n        // M (uint32)\n        var mBE = m.bigEndian\n        putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })\n        // data\n        putTLV(0x03, data)\n        if let typesData = types?.toData() {\n            putTLV(0x04, typesData)\n        }\n        if let ts = sinceTimestamp {\n            var tsBE = ts.bigEndian\n            putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })\n        }\n        if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {\n            putTLV(0x06, fidData)\n        }\n        return out\n    }\n    \n    static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {\n        var off = 0\n        var p: Int? = nil\n        var m: UInt32? = nil\n        var payload: Data? = nil\n        var types: SyncTypeFlags? = nil\n        var sinceTimestamp: UInt64? = nil\n        var fragmentIdFilter: String? = nil\n\n        while off + 3 <= data.count {\n            let t = Int(data[off]); off += 1\n            guard off + 2 <= data.count else { return nil }\n            let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2\n            guard off + len <= data.count else { return nil }\n            let v = data.subdata(in: off..<(off+len)); off += len\n            switch t {\n            case 0x01:\n                if v.count == 1 { p = Int(v[0]) }\n            case 0x02:\n                if v.count == 4 {\n                    var mm: UInt32 = 0\n                    for b in v { mm = (mm << 8) | UInt32(b) }\n                    m = mm\n                }\n            case 0x03:\n                if v.count > maxAcceptBytes { return nil }\n                payload = v\n            case 0x04:\n                if let decoded = SyncTypeFlags.decode(v) {\n                    types = decoded\n                }\n            case 0x05:\n                if v.count == 8 {\n                    var ts: UInt64 = 0\n                    for b in v { ts = (ts << 8) | UInt64(b) }\n                    sinceTimestamp = ts\n                }\n            case 0x06:\n                if let fid = String(data: v, encoding: .utf8) {\n                    fragmentIdFilter = fid\n                }\n            default:\n                break // forward compatible; ignore unknown TLVs\n            }\n        }\n\n        guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }\n        return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)\n    }\n}\n"
  },
  {
    "path": "bitchat/Noise/NoiseProtocol.swift",
    "content": "//\n// NoiseProtocol.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\n///\n/// # NoiseProtocol\n///\n/// A complete implementation of the Noise Protocol Framework for end-to-end\n/// encryption in BitChat. This file contains the core cryptographic primitives\n/// and handshake logic that enable secure communication between peers.\n///\n/// ## Overview\n/// The Noise Protocol Framework is a modern cryptographic framework designed\n/// for building secure protocols. BitChat uses Noise to provide:\n/// - Mutual authentication between peers\n/// - Forward secrecy for all messages\n/// - Protection against replay attacks\n/// - Minimal round trips for connection establishment\n///\n/// ## Implementation Details\n/// This implementation follows the Noise specification exactly, using:\n/// - **Pattern**: XX (most versatile, provides mutual authentication)\n/// - **DH**: Curve25519 (X25519 key exchange)\n/// - **Cipher**: ChaCha20-Poly1305 (AEAD encryption)\n/// - **Hash**: SHA-256 (for key derivation and authentication)\n///\n/// ## Security Properties\n/// The XX handshake pattern provides:\n/// 1. **Identity Hiding**: Both parties' identities are encrypted\n/// 2. **Forward Secrecy**: Past sessions remain secure if keys are compromised\n/// 3. **Key Compromise Impersonation Resistance**: Compromised static key doesn't allow impersonation to that party\n/// 4. **Mutual Authentication**: Both parties verify each other's identity\n///\n/// ## Handshake Flow (XX Pattern)\n/// ```\n/// Initiator                              Responder\n/// ---------                              ---------\n/// -> e                                   (ephemeral key)\n/// <- e, ee, s, es                       (ephemeral, DH, static encrypted, DH)\n/// -> s, se                              (static encrypted, DH)\n/// ```\n///\n/// ## Key Components\n/// - **NoiseCipherState**: Manages symmetric encryption with nonce tracking\n/// - **NoiseSymmetricState**: Handles key derivation and handshake hashing\n/// - **NoiseHandshakeState**: Orchestrates the complete handshake process\n///\n/// ## Replay Protection\n/// Implements sliding window replay protection to prevent message replay attacks:\n/// - Tracks nonces within a 1024-message window\n/// - Rejects duplicate or too-old nonces\n/// - Handles out-of-order message delivery\n///\n/// ## Usage Example\n/// ```swift\n/// let handshake = NoiseHandshakeState(\n///     pattern: .XX,\n///     role: .initiator,\n///     localStatic: staticKeyPair\n/// )\n/// let messageBuffer = handshake.writeMessage(payload: Data())\n/// // Send messageBuffer to peer...\n/// ```\n///\n/// ## Security Considerations\n/// - Static keys must be generated using secure random sources\n/// - Keys should be stored securely (e.g., in Keychain)\n/// - Handshake state must not be reused after completion\n/// - Transport messages have a nonce limit (2^64-1)\n///\n/// ## References\n/// - Noise Protocol Framework: http://www.noiseprotocol.org/\n/// - Noise Specification: http://www.noiseprotocol.org/noise.html\n///\n\nimport BitLogger\nimport Foundation\nimport CryptoKit\n\n// Core Noise Protocol implementation\n// Based on the Noise Protocol Framework specification\n\n// MARK: - Constants and Types\n\n/// Supported Noise handshake patterns.\n/// Each pattern provides different security properties and authentication guarantees.\nenum NoisePattern {\n    case XX  // Most versatile, mutual authentication\n    case IK  // Initiator knows responder's static key\n    case NK  // Anonymous initiator\n}\n\nenum NoiseRole {\n    case initiator\n    case responder\n}\n\nenum NoiseMessagePattern {\n    case e     // Ephemeral key\n    case s     // Static key\n    case ee    // DH(ephemeral, ephemeral)\n    case es    // DH(ephemeral, static)\n    case se    // DH(static, ephemeral)\n    case ss    // DH(static, static)\n}\n\n// MARK: - Noise Protocol Configuration\n\nstruct NoiseProtocolName {\n    let pattern: String\n    let dh: String = \"25519\"        // Curve25519\n    let cipher: String = \"ChaChaPoly\" // ChaCha20-Poly1305\n    let hash: String = \"SHA256\"      // SHA-256\n    \n    var fullName: String {\n        \"Noise_\\(pattern)_\\(dh)_\\(cipher)_\\(hash)\"\n    }\n}\n\n// MARK: - Cipher State\n\n/// Manages symmetric encryption state for Noise protocol sessions.\n/// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management\n/// and replay protection using a sliding window algorithm.\n/// - Warning: Nonce reuse would be catastrophic for security\nfinal class NoiseCipherState {\n    // Constants for replay protection\n    private static let NONCE_SIZE_BYTES = 4\n    private static let REPLAY_WINDOW_SIZE = 1024\n    private static let REPLAY_WINDOW_BYTES = REPLAY_WINDOW_SIZE / 8 // 128 bytes\n    private static let HIGH_NONCE_WARNING_THRESHOLD: UInt64 = 1_000_000_000\n    \n    private var key: SymmetricKey?\n    private var nonce: UInt64 = 0\n    private var useExtractedNonce: Bool = false\n    \n    // Sliding window replay protection (only used when useExtractedNonce = true)\n    private var highestReceivedNonce: UInt64 = 0\n    private var replayWindow: [UInt8] = Array(repeating: 0, count: REPLAY_WINDOW_BYTES)\n    \n    init() {}\n    \n    init(key: SymmetricKey, useExtractedNonce: Bool = false) {\n        self.key = key\n        self.useExtractedNonce = useExtractedNonce\n    }\n    \n    deinit {\n        clearSensitiveData()\n    }\n    \n    func initializeKey(_ key: SymmetricKey) {\n        self.key = key\n        self.nonce = 0\n    }\n    \n    func hasKey() -> Bool {\n        return key != nil\n    }\n    \n    // MARK: - Sliding Window Replay Protection\n    \n    /// Check if nonce is valid for replay protection\n    /// BCH-01-010: Use safe arithmetic to prevent integer overflow\n    private func isValidNonce(_ receivedNonce: UInt64) -> Bool {\n        // Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)\n        // use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)\n        let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)\n        if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {\n            return false  // Too old, outside window\n        }\n\n        if receivedNonce > highestReceivedNonce {\n            return true  // Always accept newer nonces\n        }\n\n        let offset = Int(highestReceivedNonce - receivedNonce)\n        let byteIndex = offset / 8\n        let bitIndex = offset % 8\n\n        return (replayWindow[byteIndex] & (1 << bitIndex)) == 0  // Not yet seen\n    }\n    \n    /// Mark nonce as seen in replay window\n    private func markNonceAsSeen(_ receivedNonce: UInt64) {\n        if receivedNonce > highestReceivedNonce {\n            let shift = Int(receivedNonce - highestReceivedNonce)\n            \n            if shift >= Self.REPLAY_WINDOW_SIZE {\n                // Clear entire window - shift is too large\n                replayWindow = Array(repeating: 0, count: Self.REPLAY_WINDOW_BYTES)\n            } else {\n                // Shift window right by `shift` bits\n                for i in stride(from: Self.REPLAY_WINDOW_BYTES - 1, through: 0, by: -1) {\n                    let sourceByteIndex = i - shift / 8\n                    var newByte: UInt8 = 0\n                    \n                    if sourceByteIndex >= 0 {\n                        newByte = replayWindow[sourceByteIndex] >> (shift % 8)\n                        if sourceByteIndex > 0 && shift % 8 != 0 {\n                            newByte |= replayWindow[sourceByteIndex - 1] << (8 - shift % 8)\n                        }\n                    }\n                    \n                    replayWindow[i] = newByte\n                }\n            }\n            \n            highestReceivedNonce = receivedNonce\n            replayWindow[0] |= 1  // Mark most recent bit as seen\n        } else {\n            let offset = Int(highestReceivedNonce - receivedNonce)\n            let byteIndex = offset / 8\n            let bitIndex = offset % 8\n            replayWindow[byteIndex] |= (1 << bitIndex)\n        }\n    }\n    \n    /// Extract nonce from combined payload <nonce><ciphertext>\n    /// Returns tuple of (nonce, ciphertext) or nil if invalid\n    private func extractNonceFromCiphertextPayload(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {\n        guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {\n            return nil\n        }\n        \n        // Extract 4-byte nonce (big-endian)\n        let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)\n        let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in\n            let byteArray = bytes.bindMemory(to: UInt8.self)\n            var result: UInt64 = 0\n            for i in 0..<Self.NONCE_SIZE_BYTES {\n                result = (result << 8) | UInt64(byteArray[i])\n            }\n            return result\n        }\n        \n        // Extract ciphertext (remaining bytes)\n        let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)\n        \n        return (nonce: extractedNonce, ciphertext: Data(ciphertext))\n    }\n    \n    /// Convert nonce to 4-byte array (big-endian)\n    private func nonceToBytes(_ nonce: UInt64) -> Data {\n        var bytes = Data(count: Self.NONCE_SIZE_BYTES)\n        withUnsafeBytes(of: nonce.bigEndian) { ptr in\n            // Copy only the last 4 bytes from the 8-byte UInt64\n            let sourceBytes = ptr.bindMemory(to: UInt8.self)\n            bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))\n        }\n        return bytes\n    }\n    \n    func encrypt(plaintext: Data, associatedData: Data = Data()) throws -> Data {\n        guard let key = self.key else {\n            throw NoiseError.uninitializedCipher\n        }\n        \n        // Debug logging for nonce tracking\n        let currentNonce = nonce\n        \n        // Check if nonce exceeds 4-byte limit (UInt32 max value)\n        guard nonce <= UInt64(UInt32.max) - 1 else {\n            throw NoiseError.nonceExceeded\n        }\n        \n        // Create nonce from counter\n        var nonceData = Data(count: 12)\n        withUnsafeBytes(of: currentNonce.littleEndian) { bytes in\n            nonceData.replaceSubrange(4..<12, with: bytes)\n        }\n        \n        let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)\n        // increment local nonce\n        nonce += 1\n        \n        // Create combined payload: <nonce><ciphertext>\n        let combinedPayload: Data\n        if (useExtractedNonce) {\n            let nonceBytes = nonceToBytes(currentNonce)\n            combinedPayload = nonceBytes + sealedBox.ciphertext + sealedBox.tag\n        } else {\n            combinedPayload = sealedBox.ciphertext + sealedBox.tag\n        }\n        \n        // Log high nonce values that might indicate issues\n        if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {\n            SecureLogger.warning(\"High nonce value detected: \\(currentNonce) - consider rekeying\", category: .encryption)\n        }\n        \n        return combinedPayload\n    }\n    \n    func decrypt(ciphertext: Data, associatedData: Data = Data()) throws -> Data {\n        guard let key = self.key else {\n            throw NoiseError.uninitializedCipher\n        }\n        \n        guard ciphertext.count >= 16 else {\n            throw NoiseError.invalidCiphertext\n        }\n        \n        let encryptedData: Data\n        let tag: Data\n        let decryptionNonce: UInt64\n        \n        if useExtractedNonce {\n            // Extract nonce and ciphertext from combined payload\n            guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else {\n                SecureLogger.debug(\"Decrypt failed: Could not extract nonce from payload\")\n                throw NoiseError.invalidCiphertext\n            }\n            \n            // Validate nonce with sliding window replay protection\n            guard isValidNonce(extractedNonce) else {\n                SecureLogger.debug(\"Replay attack detected: nonce \\(extractedNonce) rejected\")\n                throw NoiseError.replayDetected\n            }\n            \n            // Split ciphertext and tag\n            encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)\n            tag = actualCiphertext.suffix(16)\n            decryptionNonce = extractedNonce\n        } else {\n            // Split ciphertext and tag\n            encryptedData = ciphertext.prefix(ciphertext.count - 16)\n            tag = ciphertext.suffix(16)\n            decryptionNonce = nonce\n        }\n        \n        // Create nonce from counter\n        var nonceData = Data(count: 12)\n        withUnsafeBytes(of: decryptionNonce.littleEndian) { bytes in\n            nonceData.replaceSubrange(4..<12, with: bytes)\n        }\n        \n        let sealedBox = try ChaChaPoly.SealedBox(\n            nonce: ChaChaPoly.Nonce(data: nonceData),\n            ciphertext: encryptedData,\n            tag: tag\n        )\n        \n        // Log high nonce values that might indicate issues\n        if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {\n            SecureLogger.warning(\"High nonce value detected: \\(decryptionNonce) - consider rekeying\", category: .encryption)\n        }\n        \n        do {\n            let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)\n\n            // BCH-01-010: Atomic nonce state update\n            // Both replay window marking and nonce increment must complete together\n            // to prevent state desynchronization. We perform both after successful\n            // decryption only, ensuring state consistency on any failure path.\n            if useExtractedNonce {\n                markNonceAsSeen(decryptionNonce)\n            }\n            nonce += 1\n\n            return plaintext\n        } catch {\n            // Decryption failed - nonce state remains unchanged (atomic rollback)\n            SecureLogger.debug(\"Decrypt failed: \\(error) for nonce \\(decryptionNonce)\")\n            SecureLogger.error(\"Decryption failed at nonce \\(decryptionNonce)\", category: .encryption)\n            throw error\n        }\n    }\n    \n    /// Securely clear sensitive cryptographic data from memory\n    func clearSensitiveData() {\n        // Clear the symmetric key\n        key = nil\n        \n        // Reset nonce\n        nonce = 0\n        highestReceivedNonce = 0\n        \n        // Clear replay window\n        for i in 0..<replayWindow.count {\n            replayWindow[i] = 0\n        }\n    }\n\n    #if DEBUG\n    func setNonceForTesting(_ nonce: UInt64) {\n        self.nonce = nonce\n    }\n\n    func extractNonceFromCiphertextPayloadForTesting(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {\n        try extractNonceFromCiphertextPayload(combinedPayload)\n    }\n    #endif\n}\n\n// MARK: - Symmetric State\n\n/// Manages the symmetric cryptographic state during Noise handshakes.\n/// Responsible for key derivation, protocol name hashing, and maintaining\n/// the chaining key that provides key separation between handshake messages.\n/// - Note: This class implements the SymmetricState object from the Noise spec\nfinal class NoiseSymmetricState {\n    private var cipherState: NoiseCipherState\n    private var chainingKey: Data\n    private var hash: Data\n    \n    init(protocolName: String) {\n        self.cipherState = NoiseCipherState()\n        \n        // Initialize with protocol name\n        let nameData = protocolName.data(using: .utf8)!\n        if nameData.count <= 32 {\n            self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)\n        } else {\n            self.hash = nameData.sha256Hash()\n        }\n        self.chainingKey = self.hash\n    }\n    \n    func mixKey(_ inputKeyMaterial: Data) {\n        let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: inputKeyMaterial, numOutputs: 2)\n        chainingKey = output[0]\n        let tempKey = SymmetricKey(data: output[1])\n        cipherState.initializeKey(tempKey)\n    }\n    \n    func mixHash(_ data: Data) {\n        hash = (hash + data).sha256Hash()\n    }\n    \n    func mixKeyAndHash(_ inputKeyMaterial: Data) {\n        let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: inputKeyMaterial, numOutputs: 3)\n        chainingKey = output[0]\n        mixHash(output[1])\n        let tempKey = SymmetricKey(data: output[2])\n        cipherState.initializeKey(tempKey)\n    }\n    \n    func getHandshakeHash() -> Data {\n        return hash\n    }\n    \n    func hasCipherKey() -> Bool {\n        return cipherState.hasKey()\n    }\n    \n    func encryptAndHash(_ plaintext: Data) throws -> Data {\n        if cipherState.hasKey() {\n            let ciphertext = try cipherState.encrypt(plaintext: plaintext, associatedData: hash)\n            mixHash(ciphertext)\n            return ciphertext\n        } else {\n            mixHash(plaintext)\n            return plaintext\n        }\n    }\n    \n    func decryptAndHash(_ ciphertext: Data) throws -> Data {\n        if cipherState.hasKey() {\n            let plaintext = try cipherState.decrypt(ciphertext: ciphertext, associatedData: hash)\n            mixHash(ciphertext)\n            return plaintext\n        } else {\n            mixHash(ciphertext)\n            return ciphertext\n        }\n    }\n    \n    func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) {\n        let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)\n        let tempKey1 = SymmetricKey(data: output[0])\n        let tempKey2 = SymmetricKey(data: output[1])\n\n        let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)\n        let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)\n\n        // BCH-01-010: Clear symmetric state after split per Noise spec\n        // The chaining key and hash should not be retained after handshake completes\n        clearSensitiveData()\n\n        return (c1, c2)\n    }\n\n    /// BCH-01-010: Securely clear sensitive cryptographic state\n    /// Called after split() to clear chaining key and hash per Noise spec\n    func clearSensitiveData() {\n        // Clear chaining key by overwriting with zeros\n        let chainingKeyCount = chainingKey.count\n        chainingKey = Data(repeating: 0, count: chainingKeyCount)\n\n        // Clear hash by overwriting with zeros\n        let hashCount = hash.count\n        hash = Data(repeating: 0, count: hashCount)\n\n        // Clear the internal cipher state\n        cipherState.clearSensitiveData()\n    }\n\n    deinit {\n        clearSensitiveData()\n    }\n\n    // HKDF implementation\n    private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {\n        let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))\n        let tempKeyData = Data(tempKey)\n        \n        var outputs: [Data] = []\n        var currentOutput = Data()\n        \n        for i in 1...numOutputs {\n            currentOutput = Data(HMAC<SHA256>.authenticationCode(\n                for: currentOutput + Data([UInt8(i)]),\n                using: SymmetricKey(data: tempKeyData)\n            ))\n            outputs.append(currentOutput)\n        }\n        \n        return outputs\n    }\n}\n\n// MARK: - Handshake State\n\n/// Orchestrates the complete Noise handshake process.\n/// This is the main interface for establishing encrypted sessions between peers.\n/// Manages the handshake state machine, message patterns, and key derivation.\n/// - Important: Each handshake instance should only be used once\nfinal class NoiseHandshakeState {\n    private let role: NoiseRole\n    private let pattern: NoisePattern\n    private let keychain: KeychainManagerProtocol\n    private var symmetricState: NoiseSymmetricState\n    \n    // Keys\n    private var localStaticPrivate: Curve25519.KeyAgreement.PrivateKey?\n    private var localStaticPublic: Curve25519.KeyAgreement.PublicKey?\n    private var localEphemeralPrivate: Curve25519.KeyAgreement.PrivateKey?\n    private var localEphemeralPublic: Curve25519.KeyAgreement.PublicKey?\n    \n    private var remoteStaticPublic: Curve25519.KeyAgreement.PublicKey?\n    private var remoteEphemeralPublic: Curve25519.KeyAgreement.PublicKey?\n    \n    // Message patterns\n    private var messagePatterns: [[NoiseMessagePattern]] = []\n    private var currentPattern = 0\n    \n    // Test support: predetermined ephemeral keys for test vectors\n    private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?\n    private var prologueData: Data\n    \n    init(\n        role: NoiseRole,\n        pattern: NoisePattern,\n        keychain: KeychainManagerProtocol,\n        localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,\n        remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,\n        prologue: Data = Data(),\n        predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil\n    ) {\n        self.role = role\n        self.pattern = pattern\n        self.keychain = keychain\n        self.prologueData = prologue\n        self.predeterminedEphemeralKey = predeterminedEphemeralKey\n        \n        // Initialize static keys\n        if let localKey = localStaticKey {\n            self.localStaticPrivate = localKey\n            self.localStaticPublic = localKey.publicKey\n        }\n        self.remoteStaticPublic = remoteStaticKey\n        \n        // Initialize protocol name\n        let protocolName = NoiseProtocolName(pattern: pattern.patternName)\n        self.symmetricState = NoiseSymmetricState(protocolName: protocolName.fullName)\n        \n        // Initialize message patterns\n        self.messagePatterns = pattern.messagePatterns\n        \n        // Mix pre-message keys according to pattern\n        mixPreMessageKeys()\n    }\n    \n    private func mixPreMessageKeys() {\n        // Mix prologue\n        symmetricState.mixHash(self.prologueData)\n        // For XX pattern, no pre-message keys\n        // For IK/NK patterns, we'd mix the responder's static key here\n        switch pattern {\n        case .XX:\n            break // No pre-message keys\n        case .IK, .NK:\n            if role == .initiator, let remoteStatic = remoteStaticPublic {\n                symmetricState.mixHash(remoteStatic.rawRepresentation)\n            } else if role == .responder, let localStatic = localStaticPublic {\n                symmetricState.mixHash(localStatic.rawRepresentation)\n            }\n        }\n    }\n    \n    func writeMessage(payload: Data = Data()) throws -> Data {\n        guard currentPattern < messagePatterns.count else {\n            throw NoiseError.handshakeComplete\n        }\n        \n        var messageBuffer = Data()\n        let patterns = messagePatterns[currentPattern]\n        \n        for pattern in patterns {\n            switch pattern {\n            case .e:\n                // Generate ephemeral key (or use predetermined key for tests)\n                if let predetermined = predeterminedEphemeralKey {\n                    localEphemeralPrivate = predetermined\n                    predeterminedEphemeralKey = nil\n                } else {\n                    localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()\n                }\n                localEphemeralPublic = localEphemeralPrivate!.publicKey\n                messageBuffer.append(localEphemeralPublic!.rawRepresentation)\n                symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)\n                \n            case .s:\n                // Send static key (encrypted if cipher is initialized)\n                guard let staticPublic = localStaticPublic else {\n                    throw NoiseError.missingLocalStaticKey\n                }\n                let encrypted = try symmetricState.encryptAndHash(staticPublic.rawRepresentation)\n                messageBuffer.append(encrypted)\n                \n            case .ee:\n                // DH(local ephemeral, remote ephemeral)\n                guard let localEphemeral = localEphemeralPrivate,\n                      let remoteEphemeral = remoteEphemeralPublic else {\n                    throw NoiseError.missingKeys\n                }\n                let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)\n                var sharedData = shared.withUnsafeBytes { Data($0) }\n                symmetricState.mixKey(sharedData)\n                // Clear sensitive shared secret\n                keychain.secureClear(&sharedData)\n                \n            case .es:\n                // DH(ephemeral, static) - direction depends on role\n                if role == .initiator {\n                    guard let localEphemeral = localEphemeralPrivate,\n                          let remoteStatic = remoteStaticPublic else {\n                        throw NoiseError.missingKeys\n                    }\n                    let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)\n                    var sharedData = shared.withUnsafeBytes { Data($0) }\n                    symmetricState.mixKey(sharedData)\n                    // Clear sensitive shared secret\n                    keychain.secureClear(&sharedData)\n                } else {\n                    guard let localStatic = localStaticPrivate,\n                          let remoteEphemeral = remoteEphemeralPublic else {\n                        throw NoiseError.missingKeys\n                    }\n                    let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)\n                    var sharedData = shared.withUnsafeBytes { Data($0) }\n                    symmetricState.mixKey(sharedData)\n                    // Clear sensitive shared secret\n                    keychain.secureClear(&sharedData)\n                }\n                \n            case .se:\n                // DH(static, ephemeral) - direction depends on role\n                if role == .initiator {\n                    guard let localStatic = localStaticPrivate,\n                          let remoteEphemeral = remoteEphemeralPublic else {\n                        throw NoiseError.missingKeys\n                    }\n                    let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)\n                    var sharedData = shared.withUnsafeBytes { Data($0) }\n                    symmetricState.mixKey(sharedData)\n                    // Clear sensitive shared secret\n                    keychain.secureClear(&sharedData)\n                } else {\n                    guard let localEphemeral = localEphemeralPrivate,\n                          let remoteStatic = remoteStaticPublic else {\n                        throw NoiseError.missingKeys\n                    }\n                    let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)\n                    var sharedData = shared.withUnsafeBytes { Data($0) }\n                    symmetricState.mixKey(sharedData)\n                    // Clear sensitive shared secret\n                    keychain.secureClear(&sharedData)\n                }\n                \n            case .ss:\n                // DH(static, static)\n                guard let localStatic = localStaticPrivate,\n                      let remoteStatic = remoteStaticPublic else {\n                    throw NoiseError.missingKeys\n                }\n                let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)\n                var sharedData = shared.withUnsafeBytes { Data($0) }\n                symmetricState.mixKey(sharedData)\n                // Clear sensitive shared secret\n                keychain.secureClear(&sharedData)\n            }\n        }\n        \n        // Encrypt payload\n        let encryptedPayload = try symmetricState.encryptAndHash(payload)\n        messageBuffer.append(encryptedPayload)\n        \n        currentPattern += 1\n        return messageBuffer\n    }\n    \n    func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data {\n        \n        guard currentPattern < messagePatterns.count else {\n            throw NoiseError.handshakeComplete\n        }\n        \n        var buffer = message\n        let patterns = messagePatterns[currentPattern]\n        \n        for pattern in patterns {\n            switch pattern {\n            case .e:\n                // Read ephemeral key\n                guard buffer.count >= 32 else {\n                    throw NoiseError.invalidMessage\n                }\n                let ephemeralData = buffer.prefix(32)\n                buffer = buffer.dropFirst(32)\n                \n                do {\n                    remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData)\n                } catch {\n                    SecureLogger.warning(\"Invalid ephemeral public key received\", category: .security)\n                    throw NoiseError.invalidMessage\n                }\n                symmetricState.mixHash(ephemeralData)\n                \n            case .s:\n                // Read static key (may be encrypted)\n                let keyLength = symmetricState.hasCipherKey() ? 48 : 32 // 32 + 16 byte tag if encrypted\n                guard buffer.count >= keyLength else {\n                    throw NoiseError.invalidMessage\n                }\n                let staticData = buffer.prefix(keyLength)\n                buffer = buffer.dropFirst(keyLength)\n                do {\n                    let decrypted = try symmetricState.decryptAndHash(staticData)\n                    remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted)\n                } catch {\n                    SecureLogger.error(.authenticationFailed(peerID: \"Unknown - handshake\"))\n                    throw NoiseError.authenticationFailure\n                }\n                \n            case .ee, .es, .se, .ss:\n                // Same DH operations as in writeMessage\n                try performDHOperation(pattern)\n            }\n        }\n        \n        // Decrypt payload\n        let payload = try symmetricState.decryptAndHash(buffer)\n        currentPattern += 1\n        \n        return payload\n    }\n    \n    private func performDHOperation(_ pattern: NoiseMessagePattern) throws {\n        switch pattern {\n        case .ee:\n            guard let localEphemeral = localEphemeralPrivate,\n                  let remoteEphemeral = remoteEphemeralPublic else {\n                throw NoiseError.missingKeys\n            }\n            let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)\n            var sharedData = shared.withUnsafeBytes { Data($0) }\n            symmetricState.mixKey(sharedData)\n            // Clear sensitive shared secret\n            keychain.secureClear(&sharedData)\n\n        case .es:\n            if role == .initiator {\n                guard let localEphemeral = localEphemeralPrivate,\n                      let remoteStatic = remoteStaticPublic else {\n                    throw NoiseError.missingKeys\n                }\n                let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)\n                var sharedData = shared.withUnsafeBytes { Data($0) }\n                symmetricState.mixKey(sharedData)\n                // Clear sensitive shared secret\n                keychain.secureClear(&sharedData)\n            } else {\n                guard let localStatic = localStaticPrivate,\n                      let remoteEphemeral = remoteEphemeralPublic else {\n                    throw NoiseError.missingKeys\n                }\n                let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)\n                var sharedData = shared.withUnsafeBytes { Data($0) }\n                symmetricState.mixKey(sharedData)\n                // Clear sensitive shared secret\n                keychain.secureClear(&sharedData)\n            }\n            \n        case .se:\n            if role == .initiator {\n                guard let localStatic = localStaticPrivate,\n                      let remoteEphemeral = remoteEphemeralPublic else {\n                    throw NoiseError.missingKeys\n                }\n                let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)\n                var sharedData = shared.withUnsafeBytes { Data($0) }\n                symmetricState.mixKey(sharedData)\n                // Clear sensitive shared secret\n                keychain.secureClear(&sharedData)\n            } else {\n                guard let localEphemeral = localEphemeralPrivate,\n                      let remoteStatic = remoteStaticPublic else {\n                    throw NoiseError.missingKeys\n                }\n                let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)\n                var sharedData = shared.withUnsafeBytes { Data($0) }\n                symmetricState.mixKey(sharedData)\n                // Clear sensitive shared secret\n                keychain.secureClear(&sharedData)\n            }\n            \n        case .ss:\n            guard let localStatic = localStaticPrivate,\n                  let remoteStatic = remoteStaticPublic else {\n                throw NoiseError.missingKeys\n            }\n            let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)\n            var sharedData = shared.withUnsafeBytes { Data($0) }\n            symmetricState.mixKey(sharedData)\n            // Clear sensitive shared secret\n            keychain.secureClear(&sharedData)\n\n        case .e, .s:\n            break\n        }\n    }\n    \n    func isHandshakeComplete() -> Bool {\n        return currentPattern >= messagePatterns.count\n    }\n    \n    func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) {\n        guard isHandshakeComplete() else {\n            throw NoiseError.handshakeNotComplete\n        }\n\n        // BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state\n        let finalHandshakeHash = symmetricState.getHandshakeHash()\n\n        let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)\n\n        // Initiator uses c1 for sending, c2 for receiving\n        // Responder uses c2 for sending, c1 for receiving\n        let ciphers = role == .initiator ? (c1, c2) : (c2, c1)\n        return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)\n    }\n    \n    func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {\n        return remoteStaticPublic\n    }\n    \n    func getHandshakeHash() -> Data {\n        return symmetricState.getHandshakeHash()\n    }\n\n    #if DEBUG\n    func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws {\n        try performDHOperation(pattern)\n    }\n\n    func setCurrentPatternForTesting(_ currentPattern: Int) {\n        self.currentPattern = currentPattern\n    }\n\n    func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) {\n        self.remoteEphemeralPublic = key\n    }\n    #endif\n}\n\n// MARK: - Pattern Extensions\n\nextension NoisePattern {\n    var patternName: String {\n        switch self {\n        case .XX: return \"XX\"\n        case .IK: return \"IK\"\n        case .NK: return \"NK\"\n        }\n    }\n    \n    var messagePatterns: [[NoiseMessagePattern]] {\n        switch self {\n        case .XX:\n            return [\n                [.e],           // -> e\n                [.e, .ee, .s, .es], // <- e, ee, s, es\n                [.s, .se]       // -> s, se\n            ]\n        case .IK:\n            return [\n                [.e, .es, .s, .ss], // -> e, es, s, ss\n                [.e, .ee, .se]      // <- e, ee, se\n            ]\n        case .NK:\n            return [\n                [.e, .es],      // -> e, es\n                [.e, .ee]       // <- e, ee\n            ]\n        }\n    }\n}\n\n// MARK: - Errors\n\nenum NoiseError: Error {\n    case uninitializedCipher\n    case invalidCiphertext\n    case handshakeComplete\n    case handshakeNotComplete\n    case missingLocalStaticKey\n    case missingKeys\n    case invalidMessage\n    case authenticationFailure\n    case invalidPublicKey\n    case replayDetected\n    case nonceExceeded\n}\n\n// MARK: - Constant-Time Operations\n\n/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks\n/// This function compares two Data objects in constant time, preventing\n/// information leakage via timing analysis.\nprivate func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {\n    guard a.count == b.count else { return false }\n\n    var result: UInt8 = 0\n    for i in 0..<a.count {\n        result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]\n    }\n    return result == 0\n}\n\n/// BCH-01-010: Constant-time check if all bytes are zero\nprivate func constantTimeIsZero(_ data: Data) -> Bool {\n    var result: UInt8 = 0\n    for byte in data {\n        result |= byte\n    }\n    return result == 0\n}\n\n// MARK: - Key Validation\n\nextension NoiseHandshakeState {\n    /// Validate a Curve25519 public key\n    /// Checks for weak/invalid keys that could compromise security\n    /// BCH-01-010: Uses constant-time operations to prevent timing side-channels\n    static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {\n        // Check key length\n        guard keyData.count == 32 else {\n            throw NoiseError.invalidPublicKey\n        }\n\n        // BCH-01-010: Constant-time check for all-zero key (point at infinity)\n        if constantTimeIsZero(keyData) {\n            throw NoiseError.invalidPublicKey\n        }\n\n        // Check for low-order points that could enable small subgroup attacks\n        // These are the known bad points for Curve25519\n        let lowOrderPoints: [Data] = [\n            Data(repeating: 0x00, count: 32), // Already checked above\n            Data([0x01] + Data(repeating: 0x00, count: 31)), // Point of order 1\n            Data([0x00] + Data(repeating: 0x00, count: 30) + [0x01]), // Another low-order point\n            Data([0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3,\n                  0xfa, 0xf1, 0x9f, 0xc4, 0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32,\n                  0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49, 0xb8, 0x00]), // Low order point\n            Data([0x5f, 0x9c, 0x95, 0xbc, 0xa3, 0x50, 0x8c, 0x24, 0xb1, 0xd0, 0xb1,\n                  0x55, 0x9c, 0x83, 0xef, 0x5b, 0x04, 0x44, 0x5c, 0xc4, 0x58, 0x1c,\n                  0x8e, 0x86, 0xd8, 0x22, 0x4e, 0xdd, 0xd0, 0x9f, 0x11, 0x57]), // Low order point\n            Data(repeating: 0xFF, count: 32), // All ones\n            Data([0xda, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,\n                  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,\n                  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), // Another bad point\n            Data([0xdb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,\n                  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,\n                  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff])  // Another bad point\n        ]\n\n        // BCH-01-010: Constant-time check against known bad points\n        // We check all points and accumulate matches to avoid early exit timing leaks\n        var foundBadPoint = false\n        for badPoint in lowOrderPoints {\n            if constantTimeCompare(keyData, badPoint) {\n                foundBadPoint = true\n            }\n        }\n\n        if foundBadPoint {\n            SecureLogger.warning(\"Low-order point detected\", category: .security)\n            throw NoiseError.invalidPublicKey\n        }\n\n        // Try to create the key - CryptoKit will validate curve points internally\n        do {\n            let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)\n            return publicKey\n        } catch {\n            // If CryptoKit rejects it, it's invalid\n            SecureLogger.warning(\"CryptoKit validation failed\", category: .security)\n            throw NoiseError.invalidPublicKey\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Noise/NoiseRateLimiter.swift",
    "content": "//\n// NoiseRateLimiter.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport BitLogger\nimport Foundation\n\nfinal class NoiseRateLimiter {\n    private var handshakeTimestamps: [PeerID: [Date]] = [:]\n    private var messageTimestamps: [PeerID: [Date]] = [:]\n    \n    // Global rate limiting\n    private var globalHandshakeTimestamps: [Date] = []\n    private var globalMessageTimestamps: [Date] = []\n    \n    private let queue = DispatchQueue(label: \"chat.bitchat.noise.ratelimit\", attributes: .concurrent)\n    \n    func allowHandshake(from peerID: PeerID) -> Bool {\n        return queue.sync(flags: .barrier) {\n            let now = Date()\n            let oneMinuteAgo = now.addingTimeInterval(-60)\n            \n            // Check global rate limit first\n            globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }\n            if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {\n                SecureLogger.warning(\"Global handshake rate limit exceeded: \\(globalHandshakeTimestamps.count)/\\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute\", category: .security)\n                return false\n            }\n            \n            // Check per-peer rate limit\n            var timestamps = handshakeTimestamps[peerID] ?? []\n            timestamps = timestamps.filter { $0 > oneMinuteAgo }\n            \n            if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {\n                SecureLogger.warning(\"Per-peer handshake rate limit exceeded for \\(peerID): \\(timestamps.count)/\\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute\", category: .security)\n                return false\n            }\n            \n            // Record new handshake\n            timestamps.append(now)\n            handshakeTimestamps[peerID] = timestamps\n            globalHandshakeTimestamps.append(now)\n            return true\n        }\n    }\n    \n    func allowMessage(from peerID: PeerID) -> Bool {\n        return queue.sync(flags: .barrier) {\n            let now = Date()\n            let oneSecondAgo = now.addingTimeInterval(-1)\n            \n            // Check global rate limit first\n            globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }\n            if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {\n                SecureLogger.warning(\"Global message rate limit exceeded: \\(globalMessageTimestamps.count)/\\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second\", category: .security)\n                return false\n            }\n            \n            // Check per-peer rate limit\n            var timestamps = messageTimestamps[peerID] ?? []\n            timestamps = timestamps.filter { $0 > oneSecondAgo }\n            \n            if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {\n                SecureLogger.warning(\"Per-peer message rate limit exceeded for \\(peerID): \\(timestamps.count)/\\(NoiseSecurityConstants.maxMessagesPerSecond) per second\", category: .security)\n                return false\n            }\n            \n            // Record new message\n            timestamps.append(now)\n            messageTimestamps[peerID] = timestamps\n            globalMessageTimestamps.append(now)\n            return true\n        }\n    }\n    \n    func reset(for peerID: PeerID) {\n        queue.async(flags: .barrier) {\n            self.handshakeTimestamps.removeValue(forKey: peerID)\n            self.messageTimestamps.removeValue(forKey: peerID)\n        }\n    }\n\n    func resetAll() {\n        queue.async(flags: .barrier) {\n            self.handshakeTimestamps.removeAll()\n            self.messageTimestamps.removeAll()\n            self.globalHandshakeTimestamps.removeAll()\n            self.globalMessageTimestamps.removeAll()\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Noise/NoiseSecurityConstants.swift",
    "content": "//\n// NoiseSecurityConstants.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\nenum NoiseSecurityConstants {\n    // Maximum message size to prevent memory exhaustion\n    static let maxMessageSize = 65535 // 64KB as per Noise spec\n    \n    // Maximum handshake message size\n    static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern\n    \n    // Session timeout - sessions older than this should be renegotiated\n    static let sessionTimeout: TimeInterval = 86400 // 24 hours\n    \n    // Maximum number of messages before rekey (2^64 - 1 is the nonce limit)\n    static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages\n    \n    // Handshake timeout - abandon incomplete handshakes\n    static let handshakeTimeout: TimeInterval = 60 // 1 minute\n    \n    // Maximum concurrent sessions per peer\n    static let maxSessionsPerPeer = 3\n    \n    // Rate limiting\n    static let maxHandshakesPerMinute = 10\n    static let maxMessagesPerSecond = 100\n    \n    // Global rate limiting (across all peers)\n    static let maxGlobalHandshakesPerMinute = 30\n    static let maxGlobalMessagesPerSecond = 500\n}\n"
  },
  {
    "path": "bitchat/Noise/NoiseSecurityError.swift",
    "content": "//\n// NoiseSecurityError.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\nenum NoiseSecurityError: Error {\n    case sessionExpired\n    case sessionExhausted\n    case messageTooLarge\n    case invalidPeerID\n    case rateLimitExceeded\n    case handshakeTimeout\n}\n"
  },
  {
    "path": "bitchat/Noise/NoiseSecurityValidator.swift",
    "content": "//\n// NoiseSecurityValidator.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\nstruct NoiseSecurityValidator {\n    \n    /// Validate message size\n    static func validateMessageSize(_ data: Data) -> Bool {\n        return data.count <= NoiseSecurityConstants.maxMessageSize\n    }\n    \n    /// Validate handshake message size\n    static func validateHandshakeMessageSize(_ data: Data) -> Bool {\n        return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize\n    }\n}\n"
  },
  {
    "path": "bitchat/Noise/NoiseSession.swift",
    "content": "//\n// NoiseSession.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport BitLogger\nimport Foundation\nimport CryptoKit\n\nclass NoiseSession {\n    let peerID: PeerID\n    let role: NoiseRole\n    private let keychain: KeychainManagerProtocol\n    private var state: NoiseSessionState = .uninitialized\n    private var handshakeState: NoiseHandshakeState?\n    private var sendCipher: NoiseCipherState?\n    private var receiveCipher: NoiseCipherState?\n    \n    // Keys\n    private let localStaticKey: Curve25519.KeyAgreement.PrivateKey\n    private var remoteStaticPublicKey: Curve25519.KeyAgreement.PublicKey?\n    \n    // Handshake messages for retransmission\n    private var sentHandshakeMessages: [Data] = []\n    private var handshakeHash: Data?\n    \n    // Thread safety\n    private let sessionQueue = DispatchQueue(label: \"chat.bitchat.noise.session\", attributes: .concurrent)\n    \n    init(\n        peerID: PeerID,\n        role: NoiseRole,\n        keychain: KeychainManagerProtocol,\n        localStaticKey: Curve25519.KeyAgreement.PrivateKey,\n        remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil\n    ) {\n        self.peerID = peerID\n        self.role = role\n        self.keychain = keychain\n        self.localStaticKey = localStaticKey\n        self.remoteStaticPublicKey = remoteStaticKey\n    }\n    \n    // MARK: - Handshake\n    \n    func startHandshake() throws -> Data {\n        return try sessionQueue.sync(flags: .barrier) {\n            guard case .uninitialized = state else {\n                throw NoiseSessionError.invalidState\n            }\n            \n            // For XX pattern, we don't need remote static key upfront\n            handshakeState = NoiseHandshakeState(\n                role: role,\n                pattern: .XX,\n                keychain: keychain,\n                localStaticKey: localStaticKey,\n                remoteStaticKey: nil\n            )\n            \n            state = .handshaking\n            \n            // Only initiator writes the first message\n            if role == .initiator {\n                let message = try handshakeState!.writeMessage()\n                sentHandshakeMessages.append(message)\n                return message\n            } else {\n                // Responder doesn't send first message in XX pattern\n                return Data()\n            }\n        }\n    }\n    \n    func processHandshakeMessage(_ message: Data) throws -> Data? {\n        return try sessionQueue.sync(flags: .barrier) {\n            SecureLogger.debug(\"NoiseSession[\\(peerID)]: Processing handshake message, current state: \\(state), role: \\(role)\")\n            \n            // Initialize handshake state if needed (for responders)\n            if state == .uninitialized && role == .responder {\n                handshakeState = NoiseHandshakeState(\n                    role: role,\n                    pattern: .XX,\n                    keychain: keychain,\n                    localStaticKey: localStaticKey,\n                    remoteStaticKey: nil\n                )\n                state = .handshaking\n                SecureLogger.debug(\"NoiseSession[\\(peerID)]: Initialized handshake state for responder\")\n            }\n            \n            guard case .handshaking = state, let handshake = handshakeState else {\n                throw NoiseSessionError.invalidState\n            }\n            \n            // Process incoming message\n            _ = try handshake.readMessage(message)\n            SecureLogger.debug(\"NoiseSession[\\(peerID)]: Read handshake message, checking if complete\")\n            \n            // Check if handshake is complete\n            if handshake.isHandshakeComplete() {\n                // Get transport ciphers and handshake hash (hash captured before split clears state)\n                let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)\n                sendCipher = send\n                receiveCipher = receive\n\n                // Store remote static key\n                remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()\n\n                // Store handshake hash for channel binding\n                handshakeHash = hash\n\n                state = .established\n                handshakeState = nil // Clear handshake state\n\n                SecureLogger.debug(\"NoiseSession[\\(peerID)]: Handshake complete (no response needed), transitioning to established\")\n                SecureLogger.info(.handshakeCompleted(peerID: peerID.id))\n\n                return nil\n            } else {\n                // Generate response\n                let response = try handshake.writeMessage()\n                sentHandshakeMessages.append(response)\n                SecureLogger.debug(\"NoiseSession[\\(peerID)]: Generated handshake response of size \\(response.count)\")\n                \n                // Check if handshake is complete after writing\n                if handshake.isHandshakeComplete() {\n                    // Get transport ciphers and handshake hash (hash captured before split clears state)\n                    let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)\n                    sendCipher = send\n                    receiveCipher = receive\n\n                    // Store remote static key\n                    remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()\n\n                    // Store handshake hash for channel binding\n                    handshakeHash = hash\n\n                    state = .established\n                    handshakeState = nil // Clear handshake state\n\n                    SecureLogger.debug(\"NoiseSession[\\(peerID)]: Handshake complete after writing response, transitioning to established\")\n                    SecureLogger.info(.handshakeCompleted(peerID: peerID.id))\n                }\n                \n                return response\n            }\n        }\n    }\n    \n    // MARK: - Transport\n    \n    func encrypt(_ plaintext: Data) throws -> Data {\n        return try sessionQueue.sync(flags: .barrier) {\n            guard case .established = state, let cipher = sendCipher else {\n                throw NoiseSessionError.notEstablished\n            }\n            \n            return try cipher.encrypt(plaintext: plaintext)\n        }\n    }\n    \n    func decrypt(_ ciphertext: Data) throws -> Data {\n        return try sessionQueue.sync(flags: .barrier) {\n            guard case .established = state, let cipher = receiveCipher else {\n                throw NoiseSessionError.notEstablished\n            }\n            \n            return try cipher.decrypt(ciphertext: ciphertext)\n        }\n    }\n    \n    // MARK: - State Management\n    \n    func getState() -> NoiseSessionState {\n        return sessionQueue.sync {\n            return state\n        }\n    }\n    \n    func isEstablished() -> Bool {\n        return sessionQueue.sync {\n            if case .established = state {\n                return true\n            }\n            return false\n        }\n    }\n    \n    func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {\n        return sessionQueue.sync {\n            return remoteStaticPublicKey\n        }\n    }\n    \n    func reset() {\n        sessionQueue.sync(flags: .barrier) {\n            let wasEstablished = state == .established\n            state = .uninitialized\n            handshakeState = nil\n            \n            // Clear sensitive cipher states\n            sendCipher?.clearSensitiveData()\n            receiveCipher?.clearSensitiveData()\n            sendCipher = nil\n            receiveCipher = nil\n            \n            // Clear sent handshake messages\n            for i in 0..<sentHandshakeMessages.count {\n                var message = sentHandshakeMessages[i]\n                keychain.secureClear(&message)\n            }\n            sentHandshakeMessages.removeAll()\n            \n            // Clear handshake hash\n            if var hash = handshakeHash {\n                keychain.secureClear(&hash)\n            }\n            handshakeHash = nil\n            \n            if wasEstablished {\n                SecureLogger.info(.sessionExpired(peerID: peerID.id))\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Noise/NoiseSessionError.swift",
    "content": "//\n// NoiseSessionError.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nenum NoiseSessionError: Error, Equatable {\n    case invalidState\n    case notEstablished\n    case sessionNotFound\n    case alreadyEstablished\n}\n"
  },
  {
    "path": "bitchat/Noise/NoiseSessionManager.swift",
    "content": "//\n// NoiseSessionManager.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport BitLogger\nimport CryptoKit\nimport Foundation\n\nfinal class NoiseSessionManager {\n    private var sessions: [PeerID: NoiseSession] = [:]\n    private let localStaticKey: Curve25519.KeyAgreement.PrivateKey\n    private let keychain: KeychainManagerProtocol\n    private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession\n    private let managerQueue = DispatchQueue(label: \"chat.bitchat.noise.manager\", attributes: .concurrent)\n    \n    // Callbacks\n    var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?\n    var onSessionFailed: ((PeerID, Error) -> Void)?\n    \n    init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {\n        self.localStaticKey = localStaticKey\n        self.keychain = keychain\n        self.sessionFactory = { peerID, role in\n            SecureNoiseSession(\n                peerID: peerID,\n                role: role,\n                keychain: keychain,\n                localStaticKey: localStaticKey\n            )\n        }\n    }\n\n    #if DEBUG\n    init(\n        localStaticKey: Curve25519.KeyAgreement.PrivateKey,\n        keychain: KeychainManagerProtocol,\n        sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession\n    ) {\n        self.localStaticKey = localStaticKey\n        self.keychain = keychain\n        self.sessionFactory = sessionFactory\n    }\n    #endif\n    \n    // MARK: - Session Management\n    \n    func getSession(for peerID: PeerID) -> NoiseSession? {\n        return managerQueue.sync {\n            return sessions[peerID]\n        }\n    }\n    \n    func removeSession(for peerID: PeerID) {\n        managerQueue.sync(flags: .barrier) {\n            if let session = sessions.removeValue(forKey: peerID) {\n                session.reset() // Clear sensitive data before removing\n            }\n        }\n    }\n\n    func removeAllSessions() {\n        managerQueue.sync(flags: .barrier) {\n            for (_, session) in sessions {\n                session.reset()\n            }\n            sessions.removeAll()\n        }\n    }\n    \n    // MARK: - Handshake Helpers\n    \n    func initiateHandshake(with peerID: PeerID) throws -> Data {\n        return try managerQueue.sync(flags: .barrier) {\n            // Check if we already have an established session\n            if let existingSession = sessions[peerID], existingSession.isEstablished() {\n                // Session already established, don't recreate\n                throw NoiseSessionError.alreadyEstablished\n            }\n            \n            // Remove any existing non-established session\n            if let existingSession = sessions[peerID], !existingSession.isEstablished() {\n                _ = sessions.removeValue(forKey: peerID)\n            }\n            \n            // Create new initiator session\n            let session = sessionFactory(peerID, .initiator)\n            sessions[peerID] = session\n            \n            do {\n                let handshakeData = try session.startHandshake()\n                return handshakeData\n            } catch {\n                // Clean up failed session\n                _ = sessions.removeValue(forKey: peerID)\n                SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))\n                throw error\n            }\n        }\n    }\n    \n    func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {\n        // Process everything within the synchronized block to prevent race conditions\n        return try managerQueue.sync(flags: .barrier) {\n            var shouldCreateNew = false\n            var existingSession: NoiseSession? = nil\n            \n            if let existing = sessions[peerID] {\n                // If we have an established session, the peer must have cleared their session\n                // for a good reason (e.g., decryption failure, restart, etc.)\n                // We should accept the new handshake to re-establish encryption\n                if existing.isEstablished() {\n                    SecureLogger.info(\"Accepting handshake from \\(peerID) despite existing session - peer likely cleared their session\", category: .session)\n                    _ = sessions.removeValue(forKey: peerID)\n                    shouldCreateNew = true\n                } else {\n                    // If we're in the middle of a handshake and receive a new initiation,\n                    // reset and start fresh (the other side may have restarted)\n                    if existing.getState() == .handshaking && message.count == 32 {\n                        _ = sessions.removeValue(forKey: peerID)\n                        shouldCreateNew = true\n                    } else {\n                        existingSession = existing\n                    }\n                }\n            } else {\n                shouldCreateNew = true\n            }\n            \n            // Get or create session\n            let session: NoiseSession\n            if shouldCreateNew {\n                let newSession = sessionFactory(peerID, .responder)\n                sessions[peerID] = newSession\n                session = newSession\n            } else {\n                session = existingSession!\n            }\n            \n            // Process the handshake message within the synchronized block\n            do {\n                let response = try session.processHandshakeMessage(message)\n                \n                // Check if session is established after processing\n                if session.isEstablished() {\n                    if let remoteKey = session.getRemoteStaticPublicKey() {\n                        // Schedule callback outside the synchronized block to prevent deadlock\n                        DispatchQueue.global().async { [weak self] in\n                            self?.onSessionEstablished?(peerID, remoteKey)\n                        }\n                    }\n                }\n                \n                return response\n            } catch {\n                // Reset the session on handshake failure so next attempt can start fresh\n                _ = sessions.removeValue(forKey: peerID)\n                \n                // Schedule callback outside the synchronized block to prevent deadlock\n                DispatchQueue.global().async { [weak self] in\n                    self?.onSessionFailed?(peerID, error)\n                }\n                \n                SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))\n                throw error\n            }\n        }\n    }\n    \n    // MARK: - Encryption/Decryption\n    \n    func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {\n        guard let session = getSession(for: peerID) else {\n            throw NoiseSessionError.sessionNotFound\n        }\n        \n        return try session.encrypt(plaintext)\n    }\n    \n    func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {\n        guard let session = getSession(for: peerID) else {\n            throw NoiseSessionError.sessionNotFound\n        }\n        \n        return try session.decrypt(ciphertext)\n    }\n    \n    // MARK: - Key Management\n    \n    func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {\n        return getSession(for: peerID)?.getRemoteStaticPublicKey()\n    }\n    \n    // MARK: - Session Rekeying\n    \n    func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {\n        return managerQueue.sync {\n            var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []\n            \n            for (peerID, session) in sessions {\n                if let secureSession = session as? SecureNoiseSession,\n                   secureSession.isEstablished(),\n                   secureSession.needsRenegotiation() {\n                    needingRekey.append((peerID: peerID, needsRekey: true))\n                }\n            }\n            \n            return needingRekey\n        }\n    }\n    \n    func initiateRekey(for peerID: PeerID) throws {\n        // Remove old session\n        removeSession(for: peerID)\n        \n        // Initiate new handshake\n        _ = try initiateHandshake(with: peerID)\n    }\n}\n"
  },
  {
    "path": "bitchat/Noise/NoiseSessionState.swift",
    "content": "//\n// NoiseSessionState.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nenum NoiseSessionState: Equatable {\n    case uninitialized\n    case handshaking\n    case established\n}\n"
  },
  {
    "path": "bitchat/Noise/SecureNoiseSession.swift",
    "content": "//\n// SecureNoiseSession.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\nfinal class SecureNoiseSession: NoiseSession {\n    private(set) var messageCount: UInt64 = 0\n    private var sessionStartTime = Date()\n    private(set) var lastActivityTime = Date()\n    \n    override func encrypt(_ plaintext: Data) throws -> Data {\n        // Check session age\n        if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {\n            throw NoiseSecurityError.sessionExpired\n        }\n        \n        // Check message count\n        if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {\n            throw NoiseSecurityError.sessionExhausted\n        }\n        \n        // Validate message size\n        guard NoiseSecurityValidator.validateMessageSize(plaintext) else {\n            throw NoiseSecurityError.messageTooLarge\n        }\n        \n        let encrypted = try super.encrypt(plaintext)\n        messageCount += 1\n        lastActivityTime = Date()\n        \n        return encrypted\n    }\n    \n    override func decrypt(_ ciphertext: Data) throws -> Data {\n        // Check session age\n        if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {\n            throw NoiseSecurityError.sessionExpired\n        }\n        \n        // Validate message size\n        guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {\n            throw NoiseSecurityError.messageTooLarge\n        }\n        \n        let decrypted = try super.decrypt(ciphertext)\n        lastActivityTime = Date()\n        \n        return decrypted\n    }\n    \n    func needsRenegotiation() -> Bool {\n        // Check if we've used more than 90% of message limit\n        let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)\n        if messageCount >= messageThreshold {\n            return true\n        }\n        \n        // Check if last activity was more than 30 minutes ago\n        if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {\n            return true\n        }\n        \n        return false\n    }\n    \n    // MARK: - Testing Support\n    #if DEBUG\n    func setLastActivityTimeForTesting(_ date: Date) {\n        lastActivityTime = date\n    }\n    \n    func setMessageCountForTesting(_ count: UInt64) {\n        messageCount = count\n    }\n\n    func setSessionStartTimeForTesting(_ date: Date) {\n        sessionStartTime = date\n    }\n    #endif\n}\n"
  },
  {
    "path": "bitchat/Nostr/Bech32.swift",
    "content": "import Foundation\n\n/// Bech32 encoding for Nostr (minimal implementation)\nenum Bech32 {\n    private static let charset = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\"\n    private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]\n    \n    static func encode(hrp: String, data: Data) throws -> String {\n        let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))\n        let checksum = createChecksum(hrp: hrp, values: values)\n        let combined = values + checksum\n        \n        return hrp + \"1\" + combined.map { \n            let index = charset.index(charset.startIndex, offsetBy: Int($0))\n            return String(charset[index])\n        }.joined()\n    }\n    \n    static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {\n        // Find the last occurrence of '1'\n        guard let separatorIndex = bech32String.lastIndex(of: \"1\") else {\n            throw Bech32Error.invalidFormat\n        }\n        \n        let hrp = String(bech32String[..<separatorIndex])\n        \n        // Validate HRP contains only ASCII characters\n        for char in hrp {\n            guard char.asciiValue != nil else {\n                throw Bech32Error.invalidCharacter\n            }\n        }\n        \n        let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])\n        \n        // Convert characters to values\n        var values = [UInt8]()\n        for char in dataString {\n            guard let index = charset.firstIndex(of: char) else {\n                throw Bech32Error.invalidCharacter\n            }\n            values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))\n        }\n        \n        // Verify checksum\n        guard values.count >= 6 else {\n            throw Bech32Error.invalidChecksum\n        }\n        \n        let payloadValues = Array(values.dropLast(6))\n        let checksum = Array(values.suffix(6))\n        let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)\n        \n        guard checksum == expectedChecksum else {\n            throw Bech32Error.invalidChecksum\n        }\n        \n        // Convert back to bytes\n        let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)\n        return (hrp: hrp, data: Data(bytes))\n    }\n    \n    enum Bech32Error: Error {\n        case invalidFormat\n        case invalidCharacter\n        case invalidChecksum\n    }\n    \n    private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {\n        var acc = 0\n        var bits = 0\n        var result = [UInt8]()\n        let maxv = (1 << to) - 1\n        \n        for value in data {\n            acc = (acc << from) | Int(value)\n            bits += from\n            \n            while bits >= to {\n                bits -= to\n                result.append(UInt8((acc >> bits) & maxv))\n            }\n        }\n        \n        if pad && bits > 0 {\n            result.append(UInt8((acc << (to - bits)) & maxv))\n        }\n        \n        return result\n    }\n    \n    private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {\n        let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]\n        let polymod = polymod(checksumValues) ^ 1\n        var checksum = [UInt8]()\n        \n        for i in 0..<6 {\n            checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))\n        }\n        \n        return checksum\n    }\n    \n    private static func hrpExpand(_ hrp: String) -> [UInt8] {\n        var result = [UInt8]()\n        for c in hrp {\n            guard let asciiValue = c.asciiValue else {\n                return [] // Return empty array for invalid input\n            }\n            result.append(UInt8(asciiValue >> 5))\n        }\n        result.append(0)\n        for c in hrp {\n            guard let asciiValue = c.asciiValue else {\n                return [] // Return empty array for invalid input\n            }\n            result.append(UInt8(asciiValue & 31))\n        }\n        return result\n    }\n    \n    private static func polymod(_ values: [UInt8]) -> Int {\n        var chk = 1\n        for value in values {\n            let b = chk >> 25\n            chk = (chk & 0x1ffffff) << 5 ^ Int(value)\n            for i in 0..<5 {\n                if (b >> i) & 1 == 1 {\n                    chk ^= generator[i]\n                }\n            }\n        }\n        return chk\n    }\n}\n"
  },
  {
    "path": "bitchat/Nostr/GeoRelayDirectory.swift",
    "content": "import BitLogger\nimport Foundation\nimport Tor\n#if os(iOS)\nimport UIKit\n#elseif os(macOS)\nimport AppKit\n#endif\n\n/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.\nstruct GeoRelayDirectoryDependencies {\n    var userDefaults: UserDefaults\n    var notificationCenter: NotificationCenter\n    var now: () -> Date\n    var remoteURL: URL\n    var fetchInterval: TimeInterval\n    var refreshCheckInterval: TimeInterval\n    var retryInitialSeconds: TimeInterval\n    var retryMaxSeconds: TimeInterval\n    var awaitTorReady: @Sendable () async -> Bool\n    var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)\n    var readData: (URL) -> Data?\n    var writeData: (Data, URL) throws -> Void\n    var cacheURL: () -> URL?\n    var bundledCSVURLs: () -> [URL]\n    var currentDirectoryPath: () -> String?\n    var retrySleep: (TimeInterval) async -> Void\n    var activeNotificationName: Notification.Name?\n    var autoStart: Bool\n}\n\nprivate extension GeoRelayDirectoryDependencies {\n    @MainActor\n    static func live() -> Self {\n#if os(iOS)\n        let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification\n#elseif os(macOS)\n        let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification\n#else\n        let activeNotificationName: Notification.Name? = nil\n#endif\n\n        return Self(\n            userDefaults: .standard,\n            notificationCenter: .default,\n            now: Date.init,\n            remoteURL: URL(string: \"https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv\")!,\n            fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,\n            refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,\n            retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,\n            retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,\n            awaitTorReady: { await TorManager.shared.awaitReady() },\n            makeFetchData: {\n                let session = TorURLSession.shared.session\n                return { request in\n                    let (data, _) = try await session.data(for: request)\n                    return data\n                }\n            },\n            readData: { try? Data(contentsOf: $0) },\n            writeData: { data, url in\n                try data.write(to: url, options: .atomic)\n            },\n            cacheURL: {\n                do {\n                    let base = try FileManager.default.url(\n                        for: .applicationSupportDirectory,\n                        in: .userDomainMask,\n                        appropriateFor: nil,\n                        create: true\n                    )\n                    let dir = base.appendingPathComponent(\"bitchat\", isDirectory: true)\n                    try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)\n                    return dir.appendingPathComponent(\"georelays_cache.csv\")\n                } catch {\n                    return nil\n                }\n            },\n            bundledCSVURLs: {\n                [\n                    Bundle.main.url(forResource: \"nostr_relays\", withExtension: \"csv\"),\n                    Bundle.main.url(forResource: \"online_relays_gps\", withExtension: \"csv\"),\n                    Bundle.main.url(forResource: \"online_relays_gps\", withExtension: \"csv\", subdirectory: \"relays\")\n                ].compactMap { $0 }\n            },\n            currentDirectoryPath: { FileManager.default.currentDirectoryPath },\n            retrySleep: { delay in\n                let nanoseconds = UInt64(delay * 1_000_000_000)\n                try? await Task.sleep(nanoseconds: nanoseconds)\n            },\n            activeNotificationName: activeNotificationName,\n            autoStart: true\n        )\n    }\n}\n\n@MainActor\nfinal class GeoRelayDirectory {\n    private final class CleanupState {\n        let notificationCenter: NotificationCenter\n        var observers: [NSObjectProtocol] = []\n        var refreshTimer: Timer?\n        var retryTask: Task<Void, Never>?\n\n        init(notificationCenter: NotificationCenter) {\n            self.notificationCenter = notificationCenter\n        }\n\n        deinit {\n            observers.forEach { notificationCenter.removeObserver($0) }\n            refreshTimer?.invalidate()\n            retryTask?.cancel()\n        }\n    }\n\n    struct Entry: Hashable, Sendable {\n        let host: String\n        let lat: Double\n        let lon: Double\n    }\n\n    private enum DetachedFetchOutcome: Sendable {\n        case success(entries: [Entry], csv: String)\n        case torNotReady\n        case invalidData\n        case network(String)\n    }\n\n    static let shared = GeoRelayDirectory()\n\n    private(set) var entries: [Entry] = []\n    private let lastFetchKey = \"georelay.lastFetchAt\"\n    private let dependencies: GeoRelayDirectoryDependencies\n    private let cleanupState: CleanupState\n\n    private var retryAttempt: Int = 0\n    private var isFetching: Bool = false\n\n    private init() {\n        self.dependencies = .live()\n        self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)\n        entries = loadLocalEntries()\n        if dependencies.autoStart {\n            registerObservers()\n            startRefreshTimer()\n            prefetchIfNeeded()\n        }\n    }\n\n    internal init(dependencies: GeoRelayDirectoryDependencies) {\n        self.dependencies = dependencies\n        self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)\n        entries = loadLocalEntries()\n        if dependencies.autoStart {\n            registerObservers()\n            startRefreshTimer()\n            prefetchIfNeeded()\n        }\n    }\n\n    /// Returns up to `count` relay URLs (wss://) closest to the geohash center.\n    func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {\n        let center = Geohash.decodeCenter(geohash)\n        return closestRelays(toLat: center.lat, lon: center.lon, count: count)\n    }\n\n    /// Returns up to `count` relay URLs (wss://) closest to the given coordinate.\n    func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {\n        guard !entries.isEmpty, count > 0 else { return [] }\n\n        if entries.count <= count {\n            return entries\n                .sorted { a, b in\n                    haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)\n                }\n                .map { \"wss://\\($0.host)\" }\n        }\n\n        var best: [(entry: Entry, distance: Double)] = []\n        best.reserveCapacity(count)\n\n        for entry in entries {\n            let distance = haversineKm(lat, lon, entry.lat, entry.lon)\n            if best.count < count {\n                let idx = best.firstIndex { $0.distance > distance } ?? best.count\n                best.insert((entry, distance), at: idx)\n            } else if let worstDistance = best.last?.distance, distance < worstDistance {\n                let idx = best.firstIndex { $0.distance > distance } ?? best.count\n                best.insert((entry, distance), at: idx)\n                best.removeLast()\n            }\n        }\n\n        return best.map { \"wss://\\($0.entry.host)\" }\n    }\n\n    // MARK: - Remote Fetch\n    func prefetchIfNeeded(force: Bool = false) {\n        guard !isFetching else { return }\n\n        let now = dependencies.now()\n        let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast\n\n        if !force {\n            guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }\n        } else if last != .distantPast,\n                  now.timeIntervalSince(last) < dependencies.retryInitialSeconds {\n            // Skip forced fetches if we just refreshed moments ago.\n            return\n        }\n\n        cancelRetry()\n        fetchRemote()\n    }\n\n    private func fetchRemote() {\n        guard !isFetching else { return }\n        isFetching = true\n\n        let request = URLRequest(\n            url: dependencies.remoteURL,\n            cachePolicy: .reloadIgnoringLocalCacheData,\n            timeoutInterval: 15\n        )\n        let awaitTorReady = dependencies.awaitTorReady\n        let fetchData = dependencies.makeFetchData()\n\n        Task { [weak self] in\n            guard let self else { return }\n\n            let outcome = await Self.fetchRemoteOutcome(\n                request: request,\n                awaitTorReady: awaitTorReady,\n                fetchData: fetchData\n            )\n\n            switch outcome {\n            case .success(let parsed, let csv):\n                self.handleFetchSuccess(entries: parsed, csv: csv)\n            case .torNotReady:\n                self.handleFetchFailure(.torNotReady)\n            case .invalidData:\n                self.handleFetchFailure(.invalidData)\n            case .network(let description):\n                self.handleFetchFailure(.network(description))\n            }\n        }\n    }\n\n    nonisolated private static func fetchRemoteOutcome(\n        request: URLRequest,\n        awaitTorReady: @escaping @Sendable () async -> Bool,\n        fetchData: @escaping @Sendable (URLRequest) async throws -> Data\n    ) async -> DetachedFetchOutcome {\n        await Task.detached(priority: .utility) {\n            let ready = await awaitTorReady()\n            guard ready else { return .torNotReady }\n\n            do {\n                let data = try await fetchData(request)\n                guard let text = String(data: data, encoding: .utf8) else {\n                    return .invalidData\n                }\n\n                let parsed = Self.parseCSV(text)\n                guard !parsed.isEmpty else {\n                    return .invalidData\n                }\n\n                return .success(entries: parsed, csv: text)\n            } catch {\n                return .network(error.localizedDescription)\n            }\n        }.value\n    }\n\n    private enum FetchFailure {\n        case torNotReady\n        case invalidData\n        case network(String)\n    }\n\n    @MainActor\n    private func handleFetchSuccess(entries parsed: [Entry], csv: String) {\n        entries = parsed\n        persistCache(csv)\n        dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)\n        SecureLogger.info(\"GeoRelayDirectory: refreshed \\(parsed.count) relays from remote\", category: .session)\n        isFetching = false\n        retryAttempt = 0\n        cancelRetry()\n    }\n\n    @MainActor\n    private func handleFetchFailure(_ reason: FetchFailure) {\n        switch reason {\n        case .torNotReady:\n            SecureLogger.warning(\"GeoRelayDirectory: Tor not ready; scheduling retry\", category: .session)\n        case .invalidData:\n            SecureLogger.warning(\"GeoRelayDirectory: remote fetch returned invalid data; scheduling retry\", category: .session)\n        case .network(let errorDescription):\n            SecureLogger.warning(\"GeoRelayDirectory: remote fetch failed with error: \\(errorDescription)\", category: .session)\n        }\n        isFetching = false\n        scheduleRetry()\n    }\n\n    @MainActor\n    private func scheduleRetry() {\n        retryAttempt = min(retryAttempt + 1, 10)\n        let base = dependencies.retryInitialSeconds\n        let maxDelay = dependencies.retryMaxSeconds\n        let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))\n        let calculated = base * multiplier\n        let delay = min(maxDelay, max(base, calculated))\n\n        cancelRetry()\n        cleanupState.retryTask = Task { [weak self] in\n            guard let self else { return }\n            await self.dependencies.retrySleep(delay)\n            guard !Task.isCancelled else { return }\n            await MainActor.run {\n                self.prefetchIfNeeded(force: true)\n            }\n        }\n    }\n\n    @MainActor\n    private func cancelRetry() {\n        cleanupState.retryTask?.cancel()\n        cleanupState.retryTask = nil\n    }\n\n    private func persistCache(_ text: String) {\n        guard let url = dependencies.cacheURL() else { return }\n        guard let data = text.data(using: .utf8) else { return }\n        do {\n            try dependencies.writeData(data, url)\n        } catch {\n            SecureLogger.warning(\"GeoRelayDirectory: failed to write cache: \\(error)\", category: .session)\n        }\n    }\n\n    // MARK: - Loading\n    private func loadLocalEntries() -> [Entry] {\n        // Prefer cached file if present\n        if let cache = dependencies.cacheURL(),\n           let data = dependencies.readData(cache),\n           let text = String(data: data, encoding: .utf8) {\n            let arr = Self.parseCSV(text)\n            if !arr.isEmpty { return arr }\n        }\n\n        // Try bundled resource(s)\n        let bundleCandidates = dependencies.bundledCSVURLs()\n\n        for url in bundleCandidates {\n            if let data = dependencies.readData(url),\n               let text = String(data: data, encoding: .utf8) {\n                let arr = Self.parseCSV(text)\n                if !arr.isEmpty { return arr }\n            }\n        }\n\n        // Try filesystem path (development/test)\n        if let cwd = dependencies.currentDirectoryPath(),\n           let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent(\"relays/online_relays_gps.csv\")),\n           let text = String(data: data, encoding: .utf8) {\n            return Self.parseCSV(text)\n        }\n\n        SecureLogger.warning(\"GeoRelayDirectory: no local CSV found; entries empty\", category: .session)\n        return []\n    }\n\n    nonisolated static func parseCSV(_ text: String) -> [Entry] {\n        var result: Set<Entry> = []\n        let lines = text.split(whereSeparator: { $0.isNewline })\n        for (idx, raw) in lines.enumerated() {\n            let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n            if line.isEmpty { continue }\n            if idx == 0 && line.lowercased().contains(\"relay url\") { continue }\n            let parts = line.split(separator: \",\").map { String($0).trimmingCharacters(in: .whitespaces) }\n            guard parts.count >= 3 else { continue }\n            var host = parts[0]\n            host = host.replacingOccurrences(of: \"https://\", with: \"\")\n            host = host.replacingOccurrences(of: \"http://\", with: \"\")\n            host = host.replacingOccurrences(of: \"wss://\", with: \"\")\n            host = host.replacingOccurrences(of: \"ws://\", with: \"\")\n            host = host.trimmingCharacters(in: CharacterSet(charactersIn: \"/\"))\n            guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }\n            result.insert(Entry(host: host, lat: lat, lon: lon))\n        }\n        return Array(result)\n    }\n\n    // MARK: - Observers & Timers\n    private func registerObservers() {\n        let center = dependencies.notificationCenter\n\n        let torReady = center.addObserver(\n            forName: .TorDidBecomeReady,\n            object: nil,\n            queue: .main\n        ) { [weak self] _ in\n            guard let self else { return }\n            Task { @MainActor in\n                self.prefetchIfNeeded(force: true)\n            }\n        }\n        cleanupState.observers.append(torReady)\n\n        if let activeNotificationName = dependencies.activeNotificationName {\n            let didBecomeActive = center.addObserver(\n                forName: activeNotificationName,\n                object: nil,\n                queue: .main\n            ) { [weak self] _ in\n                guard let self else { return }\n                Task { @MainActor in\n                    self.prefetchIfNeeded()\n                }\n            }\n            cleanupState.observers.append(didBecomeActive)\n        }\n    }\n\n    private func startRefreshTimer() {\n        cleanupState.refreshTimer?.invalidate()\n        let interval = dependencies.refreshCheckInterval\n        guard interval > 0 else { return }\n\n        let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in\n            guard let self else { return }\n            Task { @MainActor in\n                self.prefetchIfNeeded()\n            }\n        }\n        cleanupState.refreshTimer = timer\n        RunLoop.main.add(timer, forMode: .common)\n    }\n\n    var debugRetryAttempt: Int { retryAttempt }\n    var debugHasRetryTask: Bool { cleanupState.retryTask != nil }\n    var debugObserverCount: Int { cleanupState.observers.count }\n}\n\n// MARK: - Distance\nprivate func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {\n    let r = 6371.0 // Earth radius in km\n    let dLat = (lat2 - lat1) * .pi / 180\n    let dLon = (lon2 - lon1) * .pi / 180\n    let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2)\n    let c = 2 * atan2(sqrt(a), sqrt(1 - a))\n    return r * c\n}\n"
  },
  {
    "path": "bitchat/Nostr/NostrEmbeddedBitChat.swift",
    "content": "import Foundation\n\n// MARK: - BitChat-over-Nostr Adapter\n\nstruct NostrEmbeddedBitChat {\n    /// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.\n    static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {\n        // TLV-encode the private message\n        let pm = PrivateMessagePacket(messageID: messageID, content: content)\n        guard let tlv = pm.encode() else { return nil }\n\n        // Prefix with NoisePayloadType\n        var payload = Data([NoisePayloadType.privateMessage.rawValue])\n        payload.append(tlv)\n\n        // Determine 8-byte recipient ID to embed\n        let recipientID = normalizeRecipientPeerID(recipientPeerID)\n\n        let packet = BitchatPacket(\n            type: MessageType.noiseEncrypted.rawValue,\n            senderID: Data(hexString: senderPeerID.id) ?? Data(),\n            recipientID: Data(hexString: recipientID.id),\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: payload,\n            signature: nil,\n            ttl: 7\n        )\n\n        guard let data = packet.toBinaryData() else { return nil }\n        return \"bitchat1:\" + base64URLEncode(data)\n    }\n\n    /// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.\n    static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {\n        guard type == .delivered || type == .readReceipt else { return nil }\n\n        var payload = Data([type.rawValue])\n        payload.append(Data(messageID.utf8))\n\n        let recipientID = normalizeRecipientPeerID(recipientPeerID)\n\n        let packet = BitchatPacket(\n            type: MessageType.noiseEncrypted.rawValue,\n            senderID: Data(hexString: senderPeerID.id) ?? Data(),\n            recipientID: Data(hexString: recipientID.id),\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: payload,\n            signature: nil,\n            ttl: 7\n        )\n\n        guard let data = packet.toBinaryData() else { return nil }\n        return \"bitchat1:\" + base64URLEncode(data)\n    }\n\n    /// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).\n    static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {\n        guard type == .delivered || type == .readReceipt else { return nil }\n\n        var payload = Data([type.rawValue])\n        payload.append(Data(messageID.utf8))\n\n        let packet = BitchatPacket(\n            type: MessageType.noiseEncrypted.rawValue,\n            senderID: Data(hexString: senderPeerID.id) ?? Data(),\n            recipientID: nil,\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: payload,\n            signature: nil,\n            ttl: 7\n        )\n\n        guard let data = packet.toBinaryData() else { return nil }\n        return \"bitchat1:\" + base64URLEncode(data)\n    }\n\n    /// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).\n    static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {\n        let pm = PrivateMessagePacket(messageID: messageID, content: content)\n        guard let tlv = pm.encode() else { return nil }\n\n        var payload = Data([NoisePayloadType.privateMessage.rawValue])\n        payload.append(tlv)\n\n        let packet = BitchatPacket(\n            type: MessageType.noiseEncrypted.rawValue,\n            senderID: Data(hexString: senderPeerID.id) ?? Data(),\n            recipientID: nil,\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: payload,\n            signature: nil,\n            ttl: 7\n        )\n\n        guard let data = packet.toBinaryData() else { return nil }\n        return \"bitchat1:\" + base64URLEncode(data)\n    }\n\n    private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {\n        if let maybeData = Data(hexString: recipientPeerID.id) {\n            if maybeData.count == 32 {\n                // Treat as Noise static public key; derive peerID from fingerprint\n                return PeerID(publicKey: maybeData)\n            } else if maybeData.count == 8 {\n                // Already an 8-byte peer ID\n                return recipientPeerID\n            }\n        }\n        // Fallback: return as-is (expecting 16 hex chars) – caller should pass a valid peer ID\n        return recipientPeerID\n    }\n\n    /// Base64url encode without padding\n    private static func base64URLEncode(_ data: Data) -> String {\n        let b64 = data.base64EncodedString()\n        return b64\n            .replacingOccurrences(of: \"+\", with: \"-\")\n            .replacingOccurrences(of: \"/\", with: \"_\")\n            .replacingOccurrences(of: \"=\", with: \"\")\n    }\n}\n"
  },
  {
    "path": "bitchat/Nostr/NostrIdentity.swift",
    "content": "import Foundation\nimport P256K\n\n/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging\nstruct NostrIdentity: Codable {\n    let privateKey: Data\n    let publicKey: Data\n    let npub: String // Bech32-encoded public key\n    let createdAt: Date\n    \n    /// Memberwise initializer\n    init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {\n        self.privateKey = privateKey\n        self.publicKey = publicKey\n        self.npub = npub\n        self.createdAt = createdAt\n    }\n    \n    /// Generate a new Nostr identity\n    static func generate() throws -> NostrIdentity {\n        // Generate Schnorr key for Nostr\n        let schnorrKey = try P256K.Schnorr.PrivateKey()\n        let xOnlyPubkey = Data(schnorrKey.xonly.bytes)\n        let npub = try Bech32.encode(hrp: \"npub\", data: xOnlyPubkey)\n        \n        return NostrIdentity(\n            privateKey: schnorrKey.dataRepresentation,\n            publicKey: xOnlyPubkey, // Store x-only public key\n            npub: npub,\n            createdAt: Date()\n        )\n    }\n    \n    /// Initialize from existing private key data\n    init(privateKeyData: Data) throws {\n        let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyData)\n        let xOnlyPubkey = Data(schnorrKey.xonly.bytes)\n        \n        self.privateKey = privateKeyData\n        self.publicKey = xOnlyPubkey\n        self.npub = try Bech32.encode(hrp: \"npub\", data: xOnlyPubkey)\n        self.createdAt = Date()\n    }\n    \n    /// Get signing key for event signatures\n    func signingKey() throws -> P256K.Signing.PrivateKey {\n        try P256K.Signing.PrivateKey(dataRepresentation: privateKey)\n    }\n    \n    /// Get Schnorr signing key for Nostr event signatures\n    func schnorrSigningKey() throws -> P256K.Schnorr.PrivateKey {\n        try P256K.Schnorr.PrivateKey(dataRepresentation: privateKey)\n    }\n    \n    /// Get hex-encoded public key (for Nostr events)\n    var publicKeyHex: String {\n        // Public key is already stored as x-only (32 bytes)\n        return publicKey.hexEncodedString()\n    }\n}\n"
  },
  {
    "path": "bitchat/Nostr/NostrIdentityBridge.swift",
    "content": "import Foundation\nimport CryptoKit\n\n/// Bridge between Noise and Nostr identities\nfinal class NostrIdentityBridge {\n    private let keychainService = \"chat.bitchat.nostr\"\n    private let currentIdentityKey = \"nostr-current-identity\"\n    private let deviceSeedKey = \"nostr-device-seed\"\n    // In-memory cache to avoid transient keychain access issues\n    private var deviceSeedCache: Data?\n    // Cache derived identities to avoid repeated crypto during view rendering\n    private var derivedIdentityCache: [String: NostrIdentity] = [:]\n    private let cacheLock = NSLock()\n\n    private let keychain: KeychainManagerProtocol\n\n    init(keychain: KeychainManagerProtocol = KeychainManager()) {\n        self.keychain = keychain\n    }\n    \n    /// Get or create the current Nostr identity\n    func getCurrentNostrIdentity() throws -> NostrIdentity? {\n        // Check if we already have a Nostr identity\n        if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),\n           let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {\n            return identity\n        }\n        \n        // Generate new Nostr identity\n        let nostrIdentity = try NostrIdentity.generate()\n        \n        // Store it\n        let data = try JSONEncoder().encode(nostrIdentity)\n        keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)\n        \n        return nostrIdentity\n    }\n    \n    /// Associate a Nostr identity with a Noise public key (for favorites)\n    func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {\n        let key = \"nostr-noise-\\(noisePublicKey.base64EncodedString())\"\n        if let data = nostrPubkey.data(using: .utf8) {\n            keychain.save(key: key, data: data, service: keychainService, accessible: nil)\n        }\n    }\n    \n    /// Get Nostr public key associated with a Noise public key\n    func getNostrPublicKey(for noisePublicKey: Data) -> String? {\n        let key = \"nostr-noise-\\(noisePublicKey.base64EncodedString())\"\n        guard let data = keychain.load(key: key, service: keychainService),\n              let pubkey = String(data: data, encoding: .utf8) else {\n            return nil\n        }\n        return pubkey\n    }\n    \n    /// Clear all Nostr identity associations and current identity\n    func clearAllAssociations() {\n        let query: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecAttrService as String: keychainService,\n            kSecMatchLimit as String: kSecMatchLimitAll,\n            kSecReturnAttributes as String: true\n        ]\n\n        var result: AnyObject?\n        let status = SecItemCopyMatching(query as CFDictionary, &result)\n        if status == errSecSuccess, let items = result as? [[String: Any]] {\n            for item in items {\n                var deleteQuery: [String: Any] = [\n                    kSecClass as String: kSecClassGenericPassword,\n                    kSecAttrService as String: keychainService\n                ]\n                if let account = item[kSecAttrAccount as String] as? String {\n                    deleteQuery[kSecAttrAccount as String] = account\n                }\n                SecItemDelete(deleteQuery as CFDictionary)\n            }\n        } else if status == errSecItemNotFound {\n            // nothing persisted; no action needed\n        }\n\n        deviceSeedCache = nil\n    }\n\n    // MARK: - Per-Geohash Identities (Location Channels)\n\n    /// Returns a stable device seed used to derive unlinkable per-geohash identities.\n    /// Stored only on device keychain.\n    private func getOrCreateDeviceSeed() -> Data {\n        if let cached = deviceSeedCache { return cached }\n        if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {\n            // Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock\n            keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)\n            deviceSeedCache = existing\n            return existing\n        }\n        var seed = Data(count: 32)\n        _ = seed.withUnsafeMutableBytes { ptr in\n            SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)\n        }\n        // Ensure availability after first unlock to prevent unintended rotation when locked\n        keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)\n        deviceSeedCache = seed\n        return seed\n    }\n\n    /// Derive a deterministic, unlinkable Nostr identity for a given geohash.\n    /// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing\n    /// if the candidate is not a valid secp256k1 private key.\n    func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {\n        // Check cache first to avoid repeated crypto + keychain I/O during view rendering\n        cacheLock.lock()\n        if let cached = derivedIdentityCache[geohash] {\n            cacheLock.unlock()\n            return cached\n        }\n        cacheLock.unlock()\n\n        let seed = getOrCreateDeviceSeed()\n        guard let msg = geohash.data(using: .utf8) else {\n            throw NSError(domain: \"NostrIdentity\", code: -1, userInfo: [NSLocalizedDescriptionKey: \"Invalid geohash string\"])\n        }\n\n        func candidateKey(iteration: UInt32) -> Data {\n            var input = Data(msg)\n            var iterBE = iteration.bigEndian\n            withUnsafeBytes(of: &iterBE) { bytes in\n                input.append(contentsOf: bytes)\n            }\n            let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))\n            return Data(code)\n        }\n\n        // Try a few iterations to ensure a valid key can be formed\n        for i in 0..<10 {\n            let keyData = candidateKey(iteration: UInt32(i))\n            if let identity = try? NostrIdentity(privateKeyData: keyData) {\n                // Cache the result\n                cacheLock.lock()\n                derivedIdentityCache[geohash] = identity\n                cacheLock.unlock()\n                return identity\n            }\n        }\n        // As a final fallback, hash the seed+msg and try again\n        let fallback = (seed + msg).sha256Hash()\n        let identity = try NostrIdentity(privateKeyData: fallback)\n\n        // Cache the result\n        cacheLock.lock()\n        derivedIdentityCache[geohash] = identity\n        cacheLock.unlock()\n\n        return identity\n    }\n}\n"
  },
  {
    "path": "bitchat/Nostr/NostrProtocol.swift",
    "content": "import BitLogger\nimport Foundation\nimport CryptoKit\nimport P256K\nimport Security\n\n// Note: This file depends on Data extension from BinaryEncodingUtils.swift\n// Make sure BinaryEncodingUtils.swift is included in the target\n\n/// NIP-17 Protocol Implementation for Private Direct Messages\nstruct NostrProtocol {\n    \n    /// Nostr event kinds\n    enum EventKind: Int {\n        case metadata = 0\n        case textNote = 1\n        case dm = 14 // NIP-17 DM rumor kind\n        case seal = 13 // NIP-17 sealed event\n        case giftWrap = 1059 // NIP-59 gift wrap\n        case ephemeralEvent = 20000\n        case geohashPresence = 20001\n    }\n    \n    /// Create a NIP-17 private message\n    static func createPrivateMessage(\n        content: String,\n        recipientPubkey: String,\n        senderIdentity: NostrIdentity\n    ) throws -> NostrEvent {\n        \n        // Creating private message\n        \n        // 1. Create the rumor (unsigned event)\n        let rumor = NostrEvent(\n            pubkey: senderIdentity.publicKeyHex,\n            createdAt: Date(),\n            kind: .dm, // NIP-17: DM rumor kind 14\n            tags: [],\n            content: content\n        )\n        \n        // 2. Create ephemeral key for this message\n        let ephemeralKey = try P256K.Schnorr.PrivateKey()\n        // Created ephemeral key for seal\n        \n        // 3. Seal the rumor (encrypt to recipient)\n        let sealedEvent = try createSeal(\n            rumor: rumor,\n            recipientPubkey: recipientPubkey,\n            senderKey: ephemeralKey\n        )\n        \n        // 4. Gift wrap the sealed event (encrypt to recipient again)\n        let giftWrap = try createGiftWrap(\n            seal: sealedEvent,\n            recipientPubkey: recipientPubkey,\n            senderKey: ephemeralKey\n        )\n        \n        // Created gift wrap\n        \n        return giftWrap\n    }\n    \n    /// Decrypt a received NIP-17 message\n    /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)\n    static func decryptPrivateMessage(\n        giftWrap: NostrEvent,\n        recipientIdentity: NostrIdentity\n    ) throws -> (content: String, senderPubkey: String, timestamp: Int) {\n        \n        // Starting decryption\n        \n        // 1. Unwrap the gift wrap\n        let seal: NostrEvent\n        do {\n            seal = try unwrapGiftWrap(\n                giftWrap: giftWrap,\n                recipientKey: recipientIdentity.schnorrSigningKey()\n            )\n            // Successfully unwrapped gift wrap\n        } catch {\n            SecureLogger.error(\"❌ Failed to unwrap gift wrap: \\(error)\", category: .session)\n            throw error\n        }\n        \n        // 2. Open the seal\n        let rumor: NostrEvent\n        do {\n            rumor = try openSeal(\n                seal: seal,\n                recipientKey: recipientIdentity.schnorrSigningKey()\n            )\n            // Successfully opened seal\n        } catch {\n            SecureLogger.error(\"❌ Failed to open seal: \\(error)\", category: .session)\n            throw error\n        }\n        \n        return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)\n    }\n\n    /// Create a geohash-scoped ephemeral public message (kind 20000)\n    static func createEphemeralGeohashEvent(\n        content: String,\n        geohash: String,\n        senderIdentity: NostrIdentity,\n        nickname: String? = nil,\n        teleported: Bool = false\n    ) throws -> NostrEvent {\n        var tags = [[\"g\", geohash]]\n        if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {\n            tags.append([\"n\", nickname])\n        }\n        if teleported {\n            tags.append([\"t\", \"teleport\"])\n        }\n        let event = NostrEvent(\n            pubkey: senderIdentity.publicKeyHex,\n            createdAt: Date(),\n            kind: .ephemeralEvent,\n            tags: tags,\n            content: content\n        )\n        let schnorrKey = try senderIdentity.schnorrSigningKey()\n        return try event.sign(with: schnorrKey)\n    }\n\n    /// Create a geohash presence heartbeat (kind 20001)\n    /// Must contain empty content and NO nickname tag\n    static func createGeohashPresenceEvent(\n        geohash: String,\n        senderIdentity: NostrIdentity\n    ) throws -> NostrEvent {\n        let tags = [[\"g\", geohash]]\n        let event = NostrEvent(\n            pubkey: senderIdentity.publicKeyHex,\n            createdAt: Date(),\n            kind: .geohashPresence,\n            tags: tags,\n            content: \"\"\n        )\n        let schnorrKey = try senderIdentity.schnorrSigningKey()\n        return try event.sign(with: schnorrKey)\n    }\n\n    /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.\n    static func createGeohashTextNote(\n        content: String,\n        geohash: String,\n        senderIdentity: NostrIdentity,\n        nickname: String? = nil\n    ) throws -> NostrEvent {\n        var tags = [[\"g\", geohash]]\n        if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {\n            tags.append([\"n\", nickname])\n        }\n        let event = NostrEvent(\n            pubkey: senderIdentity.publicKeyHex,\n            createdAt: Date(),\n            kind: .textNote,\n            tags: tags,\n            content: content\n        )\n        let schnorrKey = try senderIdentity.schnorrSigningKey()\n        return try event.sign(with: schnorrKey)\n    }\n    \n    // MARK: - Private Methods\n    \n    private static func createSeal(\n        rumor: NostrEvent,\n        recipientPubkey: String,\n        senderKey: P256K.Schnorr.PrivateKey\n    ) throws -> NostrEvent {\n        \n        let rumorJSON = try rumor.jsonString()\n        let encrypted = try encrypt(\n            plaintext: rumorJSON,\n            recipientPubkey: recipientPubkey,\n            senderKey: senderKey\n        )\n        \n        let seal = NostrEvent(\n            pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),\n            createdAt: randomizedTimestamp(),\n            kind: .seal,\n            tags: [],\n            content: encrypted\n        )\n        \n        // Sign the seal with the sender's Schnorr private key\n        return try seal.sign(with: senderKey)\n    }\n    \n    private static func createGiftWrap(\n        seal: NostrEvent,\n        recipientPubkey: String,\n        senderKey: P256K.Schnorr.PrivateKey  // This is the ephemeral key used for the seal\n    ) throws -> NostrEvent {\n        \n        let sealJSON = try seal.jsonString()\n        \n        // Create new ephemeral key for gift wrap\n        let wrapKey = try P256K.Schnorr.PrivateKey()\n        // Creating gift wrap with ephemeral key\n        \n        // Encrypt the seal with the new ephemeral key (not the seal's key)\n        let encrypted = try encrypt(\n            plaintext: sealJSON,\n            recipientPubkey: recipientPubkey,\n            senderKey: wrapKey  // Use the gift wrap ephemeral key\n        )\n        \n        let giftWrap = NostrEvent(\n            pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),\n            createdAt: randomizedTimestamp(),\n            kind: .giftWrap,\n            tags: [[\"p\", recipientPubkey]], // Tag recipient\n            content: encrypted\n        )\n        \n        // Sign the gift wrap with the wrap Schnorr private key\n        return try giftWrap.sign(with: wrapKey)\n    }\n    \n    private static func unwrapGiftWrap(\n        giftWrap: NostrEvent,\n        recipientKey: P256K.Schnorr.PrivateKey\n    ) throws -> NostrEvent {\n        \n        // Unwrapping gift wrap\n        \n        let decrypted = try decrypt(\n            ciphertext: giftWrap.content,\n            senderPubkey: giftWrap.pubkey,\n            recipientKey: recipientKey\n        )\n        \n        guard let data = decrypted.data(using: .utf8),\n              let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n            throw NostrError.invalidEvent\n        }\n        \n        let seal = try NostrEvent(from: sealDict)\n        // Unwrapped seal\n        \n        return seal\n    }\n    \n    private static func openSeal(\n        seal: NostrEvent,\n        recipientKey: P256K.Schnorr.PrivateKey\n    ) throws -> NostrEvent {\n        \n        let decrypted = try decrypt(\n            ciphertext: seal.content,\n            senderPubkey: seal.pubkey,\n            recipientKey: recipientKey\n        )\n        \n        guard let data = decrypted.data(using: .utf8),\n              let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n            throw NostrError.invalidEvent\n        }\n        \n        return try NostrEvent(from: rumorDict)\n    }\n    \n    // MARK: - Encryption (NIP-44 v2)\n    \n    private static func encrypt(\n        plaintext: String,\n        recipientPubkey: String,\n        senderKey: P256K.Schnorr.PrivateKey\n    ) throws -> String {\n        \n        guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {\n            throw NostrError.invalidPublicKey\n        }\n        \n        // Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)\n        \n        // Derive shared secret\n        let sharedSecret = try deriveSharedSecret(\n            privateKey: senderKey,\n            publicKey: recipientPubkeyData\n        )\n        // Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)\n        let key = try deriveNIP44V2Key(from: sharedSecret)\n        \n        // 24-byte random nonce for XChaCha20-Poly1305\n        var nonce24 = Data(count: 24)\n        _ = nonce24.withUnsafeMutableBytes { ptr in\n            SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)\n        }\n        \n        let pt = Data(plaintext.utf8)\n        let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)\n        \n        // v2: base64url(nonce24 || ciphertext || tag)\n        var combined = Data()\n        combined.append(nonce24)\n        combined.append(sealed.ciphertext)\n        combined.append(sealed.tag)\n        return \"v2:\" + base64URLEncode(combined)\n    }\n    \n    private static func decrypt(\n        ciphertext: String,\n        senderPubkey: String,\n        recipientKey: P256K.Schnorr.PrivateKey\n    ) throws -> String {\n        // Expect NIP-44 v2 format\n        guard ciphertext.hasPrefix(\"v2:\") else { throw NostrError.invalidCiphertext }\n        let encoded = String(ciphertext.dropFirst(3))\n        guard let data = base64URLDecode(encoded),\n              data.count > (24 + 16),\n              let senderPubkeyData = Data(hexString: senderPubkey) else {\n            throw NostrError.invalidCiphertext\n        }\n\n        let nonce24 = data.prefix(24)\n        let rest = data.dropFirst(24)\n        let tag = rest.suffix(16)\n        let ct = rest.dropLast(16)\n\n        // Try decryption with even-Y then odd-Y when sender pubkey is x-only\n        func attemptDecrypt(using pubKeyData: Data) throws -> Data {\n            let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)\n            let key = try deriveNIP44V2Key(from: ss)\n            return try XChaCha20Poly1305Compat.open(\n                ciphertext: Data(ct),\n                tag: Data(tag),\n                key: key,\n                nonce24: Data(nonce24)\n            )\n        }\n\n        // If 32 bytes (x-only) try both parities, otherwise single try\n        if senderPubkeyData.count == 32 {\n            let even = Data([0x02]) + senderPubkeyData\n            if let pt = try? attemptDecrypt(using: even) {\n                return String(data: pt, encoding: .utf8) ?? \"\"\n            }\n            let odd = Data([0x03]) + senderPubkeyData\n            let pt = try attemptDecrypt(using: odd)\n            return String(data: pt, encoding: .utf8) ?? \"\"\n        } else {\n            let pt = try attemptDecrypt(using: senderPubkeyData)\n            return String(data: pt, encoding: .utf8) ?? \"\"\n        }\n    }\n    \n    private static func deriveSharedSecret(\n        privateKey: P256K.Schnorr.PrivateKey,\n        publicKey: Data\n    ) throws -> Data {\n        // Deriving shared secret\n        \n        // Convert Schnorr private key to KeyAgreement private key\n        let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(\n            dataRepresentation: privateKey.dataRepresentation\n        )\n        \n        // Create KeyAgreement public key from the public key data\n        // For ECDH, we need the full 33-byte compressed public key (with 0x02 or 0x03 prefix)\n        var fullPublicKey = Data()\n        if publicKey.count == 32 { // X-only key, need to add prefix\n            // For x-only keys in Nostr/Bitcoin, we need to try both possible Y coordinates\n            // First try with even Y (0x02 prefix)\n            fullPublicKey.append(0x02)\n            fullPublicKey.append(publicKey)\n            // Trying with even Y coordinate\n        } else {\n            fullPublicKey = publicKey\n        }\n        \n        // Try to create public key, if it fails with even Y, try odd Y\n        let keyAgreementPublicKey: P256K.KeyAgreement.PublicKey\n        do {\n            keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(\n                dataRepresentation: fullPublicKey,\n                format: .compressed\n            )\n        } catch {\n            if publicKey.count == 32 {\n                // Try with odd Y (0x03 prefix)\n                // Even Y failed, trying odd Y\n                fullPublicKey = Data()\n                fullPublicKey.append(0x03)\n                fullPublicKey.append(publicKey)\n                keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(\n                    dataRepresentation: fullPublicKey,\n                    format: .compressed\n                )\n            } else {\n                throw error\n            }\n        }\n        \n        // Perform ECDH\n        let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(\n            with: keyAgreementPublicKey,\n            format: .compressed\n        )\n        \n        // Convert SharedSecret to Data\n        let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }\n        // ECDH shared secret derived\n        \n        // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key\n        return sharedSecretData\n    }\n    \n    // Direct version that doesn't try to add prefixes\n    private static func deriveSharedSecretDirect(\n        privateKey: P256K.Schnorr.PrivateKey,\n        publicKey: Data\n    ) throws -> Data {\n        // Direct shared secret calculation\n        \n        // Convert Schnorr private key to KeyAgreement private key\n        let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(\n            dataRepresentation: privateKey.dataRepresentation\n        )\n        \n        // Use the public key as-is (should already have prefix)\n        let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(\n            dataRepresentation: publicKey,\n            format: .compressed\n        )\n        \n        // Perform ECDH\n        let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(\n            with: keyAgreementPublicKey,\n            format: .compressed\n        )\n        \n        // Convert SharedSecret to Data\n        let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }\n        \n        // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key\n        return sharedSecretData\n    }\n    \n    private static func randomizedTimestamp() -> Date {\n        // Add random offset to current time for privacy\n        // This prevents timing correlation attacks while the actual message timestamp\n        // is preserved in the encrypted rumor\n        let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes\n        let now = Date()\n        let randomized = now.addingTimeInterval(offset)\n        \n        // Log with explicit UTC and local time for debugging\n        let formatter = DateFormatter()\n        //\n        formatter.dateFormat = \"yyyy-MM-dd HH:mm:ss\"\n        formatter.timeZone = TimeZone(abbreviation: \"UTC\")\n        \n        formatter.timeZone = TimeZone.current\n        \n        // Timestamp randomized for privacy\n        \n        return randomized\n    }\n}\n\n/// Nostr Event structure\nstruct NostrEvent: Codable {\n    var id: String\n    let pubkey: String\n    let created_at: Int\n    let kind: Int\n    let tags: [[String]]\n    let content: String\n    var sig: String?\n    \n    init(\n        pubkey: String,\n        createdAt: Date,\n        kind: NostrProtocol.EventKind,\n        tags: [[String]],\n        content: String\n    ) {\n        self.pubkey = pubkey\n        self.created_at = Int(createdAt.timeIntervalSince1970)\n        self.kind = kind.rawValue\n        self.tags = tags\n        self.content = content\n        self.sig = nil\n        self.id = \"\" // Will be set during signing\n    }\n    \n    init(from dict: [String: Any]) throws {\n        guard let pubkey = dict[\"pubkey\"] as? String,\n              let createdAt = dict[\"created_at\"] as? Int,\n              let kind = dict[\"kind\"] as? Int,\n              let tags = dict[\"tags\"] as? [[String]],\n              let content = dict[\"content\"] as? String else {\n            throw NostrError.invalidEvent\n        }\n        \n        self.id = dict[\"id\"] as? String ?? \"\"\n        self.pubkey = pubkey\n        self.created_at = createdAt\n        self.kind = kind\n        self.tags = tags\n        self.content = content\n        self.sig = dict[\"sig\"] as? String\n    }\n    \n    func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {\n        let (eventId, eventIdHash) = try calculateEventId()\n        \n        // Sign with Schnorr (BIP-340)\n        var messageBytes = [UInt8](eventIdHash)\n        var auxRand = [UInt8](repeating: 0, count: 32)\n        _ = auxRand.withUnsafeMutableBytes { ptr in\n            SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)\n        }\n        let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)\n        \n        let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()\n        \n        var signed = self\n        signed.id = eventId\n        signed.sig = signatureHex\n        return signed\n    }\n\n    /// Validate that the event ID and Schnorr signature match the content and pubkey.\n    /// Returns false when the signature is missing, malformed, or does not verify.\n    func isValidSignature() -> Bool {\n        guard let sig = sig,\n              let sigData = Data(hexString: sig),\n              let pubData = Data(hexString: pubkey),\n              sigData.count == 64,\n              pubData.count == 32,\n              let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),\n              let (expectedId, eventHash) = try? calculateEventId(),\n              expectedId == id\n        else {\n            return false\n        }\n\n        var messageBytes = [UInt8](eventHash)\n        let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)\n        return xonly.isValid(signature, for: &messageBytes)\n    }\n    \n    private func calculateEventId() throws -> (String, Data) {\n        let serialized = [\n            0,\n            pubkey,\n            created_at,\n            kind,\n            tags,\n            content\n        ] as [Any]\n        \n        let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])\n        return (data.sha256Fingerprint(), data.sha256Hash())\n    }\n    \n    func jsonString() throws -> String {\n        let encoder = JSONEncoder()\n        encoder.outputFormatting = [.withoutEscapingSlashes]\n        let data = try encoder.encode(self)\n        return String(data: data, encoding: .utf8) ?? \"\"\n    }\n}\n\nenum NostrError: Error {\n    case invalidPublicKey\n    case invalidPrivateKey\n    case invalidEvent\n    case invalidCiphertext\n    case signingFailed\n    case encryptionFailed\n}\n\n// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)\n\nprivate extension NostrProtocol {\n    static func base64URLEncode(_ data: Data) -> String {\n        return data.base64EncodedString()\n            .replacingOccurrences(of: \"+\", with: \"-\")\n            .replacingOccurrences(of: \"/\", with: \"_\")\n            .replacingOccurrences(of: \"=\", with: \"\")\n    }\n\n    static func base64URLDecode(_ s: String) -> Data? {\n        var str = s\n        let pad = (4 - (str.count % 4)) % 4\n        if pad > 0 { str += String(repeating: \"=\", count: pad) }\n        str = str.replacingOccurrences(of: \"-\", with: \"+\").replacingOccurrences(of: \"_\", with: \"/\")\n        return Data(base64Encoded: str)\n    }\n\n    static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {\n        let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(\n            inputKeyMaterial: SymmetricKey(data: sharedSecretData),\n            salt: Data(),\n            info: \"nip44-v2\".data(using: .utf8)!,\n            outputByteCount: 32\n        )\n        return derivedKey.withUnsafeBytes { Data($0) }\n    }\n}\n"
  },
  {
    "path": "bitchat/Nostr/NostrRelayManager.swift",
    "content": "import BitLogger\nimport Foundation\nimport Network\nimport Combine\nimport Tor\n\nprotocol NostrRelayConnectionProtocol: AnyObject {\n    func resume()\n    func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)\n    func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)\n    func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void)\n    func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)\n}\n\nprotocol NostrRelaySessionProtocol {\n    func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol\n}\n\nprivate final class URLSessionWebSocketTaskAdapter: NostrRelayConnectionProtocol {\n    private let base: URLSessionWebSocketTask\n\n    init(base: URLSessionWebSocketTask) {\n        self.base = base\n    }\n\n    func resume() {\n        base.resume()\n    }\n\n    func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {\n        base.cancel(with: closeCode, reason: reason)\n    }\n\n    func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {\n        base.send(message, completionHandler: completionHandler)\n    }\n\n    func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {\n        base.receive(completionHandler: completionHandler)\n    }\n\n    func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {\n        base.sendPing(pongReceiveHandler: pongReceiveHandler)\n    }\n}\n\nprivate struct URLSessionAdapter: NostrRelaySessionProtocol {\n    let base: URLSession\n\n    func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {\n        URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))\n    }\n}\n\nstruct NostrRelayManagerDependencies {\n    var activationAllowed: () -> Bool\n    var userTorEnabled: () -> Bool\n    var hasMutualFavorites: () -> Bool\n    var hasLocationPermission: () -> Bool\n    var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>\n    var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>\n    var torEnforced: () -> Bool\n    var torIsReady: () -> Bool\n    var torIsForeground: () -> Bool\n    var awaitTorReady: (@escaping (Bool) -> Void) -> Void\n    var makeSession: () -> NostrRelaySessionProtocol\n    var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void\n    var now: () -> Date\n}\n\nprivate extension NostrRelayManagerDependencies {\n    @MainActor\n    static func live() -> Self {\n        Self(\n            activationAllowed: { NetworkActivationService.shared.activationAllowed },\n            userTorEnabled: { NetworkActivationService.shared.userTorEnabled },\n            hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },\n            hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },\n            mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),\n            locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),\n            torEnforced: { TorManager.shared.torEnforced },\n            torIsReady: { TorManager.shared.isReady },\n            torIsForeground: { TorManager.shared.isForeground() },\n            awaitTorReady: { completion in\n                Task.detached {\n                    let ready = await TorManager.shared.awaitReady()\n                    await MainActor.run {\n                        completion(ready)\n                    }\n                }\n            },\n            makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) },\n            scheduleAfter: { delay, action in\n                DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)\n            },\n            now: Date.init\n        )\n    }\n}\n\n/// Manages WebSocket connections to Nostr relays\n@MainActor\nfinal class NostrRelayManager: ObservableObject {\n    static let shared = NostrRelayManager()\n    // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info\n    private(set) static var pendingGiftWrapIDs = Set<String>()\n    static func registerPendingGiftWrap(id: String) {\n        pendingGiftWrapIDs.insert(id)\n    }\n    \n    struct Relay: Identifiable {\n        let id = UUID()\n        let url: String\n        var isConnected: Bool = false\n        var lastError: Error?\n        var lastConnectedAt: Date?\n        var messagesSent: Int = 0\n        var messagesReceived: Int = 0\n        var reconnectAttempts: Int = 0\n        var lastDisconnectedAt: Date?\n        var nextReconnectTime: Date?\n    }\n    \n    // Default relay list (can be customized)\n    private static let defaultRelays = [\n        \"wss://relay.damus.io\",\n        \"wss://nos.lol\",\n        \"wss://relay.primal.net\",\n        \"wss://offchain.pub\",\n        \"wss://nostr21.com\"\n        // For local testing, you can add: \"ws://localhost:8080\"\n    ]\n    private static let defaultRelaySet = Set(defaultRelays)\n    \n    @Published private(set) var relays: [Relay] = []\n    @Published private(set) var isConnected = false\n    \n    private let dependencies: NostrRelayManagerDependencies\n    private var allowDefaultRelays: Bool = false\n    private var hasMutualFavorites: Bool = false\n    private var hasLocationPermission: Bool = false\n    private var connections: [String: NostrRelayConnectionProtocol] = [:]\n    private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs\n    private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)\n    private var messageHandlers: [String: (NostrEvent) -> Void] = [:]\n    // Coalesce duplicate subscribe requests for the same id within a short window\n    private var subscribeCoalesce: [String: Date] = [:]\n    private var cancellables = Set<AnyCancellable>()\n\n    // Track EOSE per subscription to signal when initial stored events are done\n    private struct EOSETracker {\n        var pendingRelays: Set<String>\n        var callback: () -> Void\n        var timer: Timer?\n    }\n    private var eoseTrackers: [String: EOSETracker] = [:]\n    \n    // Message queue for reliability\n    // Pending sends held only for relays that are not yet connected.\n    private struct PendingSend {\n        var event: NostrEvent\n        var pendingRelays: Set<String>\n    }\n    private var messageQueue: [PendingSend] = []\n    private let messageQueueLock = NSLock()\n    private let encoder = JSONEncoder()\n    private var shouldUseTor: Bool { dependencies.userTorEnabled() }\n    \n    // Exponential backoff configuration\n    private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds\n    private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds\n    private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier\n    private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts\n    \n    // Bump generation to invalidate scheduled reconnects when we reset/disconnect\n    private var connectionGeneration: Int = 0\n    \n    init() {\n        self.dependencies = .live()\n        hasMutualFavorites = dependencies.hasMutualFavorites()\n        hasLocationPermission = dependencies.hasLocationPermission()\n        applyDefaultRelayPolicy(force: true)\n        // Deterministic JSON shape for outbound requests\n        self.encoder.outputFormatting = .sortedKeys\n        dependencies.mutualFavoritesPublisher\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] favorites in\n                guard let self = self else { return }\n                self.hasMutualFavorites = !favorites.isEmpty\n                self.applyDefaultRelayPolicy()\n            }\n            .store(in: &cancellables)\n        dependencies.locationPermissionPublisher\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] state in\n                guard let self = self else { return }\n                let authorized = (state == .authorized)\n                if authorized == self.hasLocationPermission { return }\n                self.hasLocationPermission = authorized\n                self.applyDefaultRelayPolicy()\n            }\n            .store(in: &cancellables)\n    }\n\n    internal init(dependencies: NostrRelayManagerDependencies) {\n        self.dependencies = dependencies\n        hasMutualFavorites = dependencies.hasMutualFavorites()\n        hasLocationPermission = dependencies.hasLocationPermission()\n        applyDefaultRelayPolicy(force: true)\n        // Deterministic JSON shape for outbound requests\n        self.encoder.outputFormatting = .sortedKeys\n        dependencies.mutualFavoritesPublisher\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] favorites in\n                guard let self = self else { return }\n                self.hasMutualFavorites = !favorites.isEmpty\n                self.applyDefaultRelayPolicy()\n            }\n            .store(in: &cancellables)\n        dependencies.locationPermissionPublisher\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] state in\n                guard let self = self else { return }\n                let authorized = (state == .authorized)\n                if authorized == self.hasLocationPermission { return }\n                self.hasLocationPermission = authorized\n                self.applyDefaultRelayPolicy()\n            }\n            .store(in: &cancellables)\n    }\n    \n    /// Connect to all configured relays\n    func connect() {\n        // Global network policy gate\n        guard dependencies.activationAllowed() else { return }\n        if shouldUseTor {\n            // Ensure Tor is started early and wait for readiness off-main; then hop back to connect.\n            dependencies.awaitTorReady { [weak self] ready in\n                guard let self = self else { return }\n                if !ready {\n                    SecureLogger.error(\"❌ Tor not ready; aborting relay connections (fail-closed)\", category: .session)\n                    return\n                }\n                SecureLogger.debug(\"🌐 Connecting to \\(self.relays.count) Nostr relays (via Tor)\", category: .session)\n                for relay in self.relays {\n                    self.connectToRelay(relay.url)\n                }\n            }\n        } else {\n            SecureLogger.debug(\"🌐 Connecting to \\(self.relays.count) Nostr relays (direct)\", category: .session)\n            for relay in self.relays {\n                connectToRelay(relay.url)\n            }\n        }\n    }\n    \n    /// Disconnect from all relays\n    func disconnect() {\n        connectionGeneration &+= 1\n        for (_, task) in connections {\n            task.cancel(with: .goingAway, reason: nil)\n        }\n        connections.removeAll()\n        // Clear known subscriptions and any queued subs since connections are gone\n        subscriptions.removeAll()\n        pendingSubscriptions.removeAll()\n        updateConnectionStatus()\n    }\n    \n    /// Ensure connections exist to the given relay URLs (idempotent).\n    func ensureConnections(to relayUrls: [String]) {\n        // Global network policy gate\n        guard dependencies.activationAllowed() else { return }\n        let targets = allowedRelayList(from: relayUrls)\n        guard !targets.isEmpty else { return }\n        if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {\n            // Defer until Tor is fully ready; avoid queuing connection attempts early\n            dependencies.awaitTorReady { [weak self] ready in\n                guard let self = self else { return }\n                if ready { self.ensureConnections(to: relayUrls) }\n            }\n            return\n        }\n        var existing = Set(relays.map { $0.url })\n        for url in targets where !existing.contains(url) {\n            relays.append(Relay(url: url))\n            existing.insert(url)\n        }\n        for url in targets where connections[url] == nil {\n            connectToRelay(url)\n        }\n    }\n\n    /// Send an event to specified relays (or all if none specified)\n    func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {\n        // Global network policy gate\n        guard dependencies.activationAllowed() else { return }\n        if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {\n            // Defer sends until Tor is ready to avoid premature queueing\n            dependencies.awaitTorReady { [weak self] ready in\n                guard let self = self else { return }\n                if ready { self.sendEvent(event, to: relayUrls) }\n            }\n            return\n        }\n        let requestedRelays = relayUrls ?? Self.defaultRelays\n        let targetRelays = allowedRelayList(from: requestedRelays)\n        guard !targetRelays.isEmpty else { return }\n        ensureConnections(to: targetRelays)\n\n        // Attempt immediate send to relays with active connections; queue the rest\n        var stillPending = Set<String>()\n        for relayUrl in targetRelays {\n            if let connection = connections[relayUrl] {\n                sendToRelay(event: event, connection: connection, relayUrl: relayUrl)\n            } else {\n                stillPending.insert(relayUrl)\n            }\n        }\n        if !stillPending.isEmpty {\n            messageQueueLock.lock()\n            messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))\n            messageQueueLock.unlock()\n        }\n    }\n\n    /// Try to flush any queued messages for relays that are now connected.\n    private func flushMessageQueue(for relayUrl: String? = nil) {\n        messageQueueLock.lock()\n        defer { messageQueueLock.unlock() }\n        guard !messageQueue.isEmpty else { return }\n        if let target = relayUrl {\n            // Flush only for a specific relay\n            for i in (0..<messageQueue.count).reversed() {\n                var item = messageQueue[i]\n                if item.pendingRelays.contains(target), let conn = connections[target] {\n                    sendToRelay(event: item.event, connection: conn, relayUrl: target)\n                    item.pendingRelays.remove(target)\n                    if item.pendingRelays.isEmpty {\n                        messageQueue.remove(at: i)\n                    } else {\n                        messageQueue[i] = item\n                    }\n                }\n            }\n        } else {\n            // Flush for any relays that now have connections\n            for i in (0..<messageQueue.count).reversed() {\n                var item = messageQueue[i]\n                for url in item.pendingRelays {\n                    if let conn = connections[url] {\n                        sendToRelay(event: item.event, connection: conn, relayUrl: url)\n                        item.pendingRelays.remove(url)\n                    }\n                }\n                if item.pendingRelays.isEmpty {\n                    messageQueue.remove(at: i)\n                } else {\n                    messageQueue[i] = item\n                }\n            }\n        }\n    }\n    \n    /// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.\n    func subscribe(\n        filter: NostrFilter,\n        id: String = UUID().uuidString,\n        relayUrls: [String]? = nil,\n        handler: @escaping (NostrEvent) -> Void,\n        onEOSE: (() -> Void)? = nil\n    ) {\n        // Global network policy gate\n        guard dependencies.activationAllowed() else { return }\n        // Coalesce rapid duplicate subscribe requests only if a handler already exists\n        let now = dependencies.now()\n        if messageHandlers[id] != nil {\n            if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {\n                return\n            }\n        }\n        subscribeCoalesce[id] = now\n        if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {\n            // Defer subscription setup until Tor is ready; avoid queuing subs early\n            dependencies.awaitTorReady { [weak self] ready in\n                guard let self = self else { return }\n                if ready {\n                    self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE)\n                }\n            }\n            return\n        }\n        messageHandlers[id] = handler\n        \n        let req = NostrRequest.subscribe(id: id, filters: [filter])\n        \n        do {\n            let message = try encoder.encode(req)\n            guard let messageString = String(data: message, encoding: .utf8) else { \n                SecureLogger.error(\"❌ Failed to encode subscription request\", category: .session)\n                return \n            }\n            \n            // SecureLogger.debug(\"📋 Subscription filter JSON: \\(messageString.prefix(200))...\", category: .session)\n            \n            // Target specific relays if provided; else default. Filter permanently failed relays.\n            let baseUrls = relayUrls ?? Self.defaultRelays\n            let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }\n            let urls = allowedRelayList(from: candidateUrls)\n            // Always queue subscriptions; sending happens when a relay reports connected\n            let existingSet = Set(relays.map { $0.url })\n            for url in urls where !existingSet.contains(url) {\n                relays.append(Relay(url: url))\n            }\n            for url in urls {\n                var map = self.pendingSubscriptions[url] ?? [:]\n                map[id] = messageString\n                self.pendingSubscriptions[url] = map\n            }\n            // Initialize EOSE tracking if requested\n            if let onEOSE = onEOSE {\n                if urls.isEmpty {\n                    onEOSE()\n                } else {\n                    var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)\n                    // Fallback timeout to avoid hanging if a relay never sends EOSE\n                    tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in\n                        Task { @MainActor in\n                            guard let self = self else { return }\n                            if let t = self.eoseTrackers[id] {\n                                t.timer?.invalidate()\n                                self.eoseTrackers.removeValue(forKey: id)\n                                onEOSE()\n                            }\n                        }\n                    }\n                    eoseTrackers[id] = tracker\n                }\n            }\n            SecureLogger.debug(\"📋 Queued subscription id=\\(id) for \\(urls.count) relay(s)\", category: .session)\n            // Ensure we actually have sockets opening to these relays so queued REQs can flush\n            ensureConnections(to: urls)\n            // If some targets are already connected, flush immediately for them\n            for url in urls {\n                if let r = relays.first(where: { $0.url == url }), r.isConnected {\n                    flushPendingSubscriptions(for: url)\n                }\n            }\n        } catch {\n            SecureLogger.error(\"❌ Failed to encode subscription request: \\(error)\", category: .session)\n        }\n    }\n\n    private func applyDefaultRelayPolicy(force: Bool = false) {\n        let shouldAllow = hasMutualFavorites || hasLocationPermission\n        if !force && shouldAllow == allowDefaultRelays { return }\n        allowDefaultRelays = shouldAllow\n        if shouldAllow {\n            var existing = Set(relays.map { $0.url })\n            for url in Self.defaultRelays where !existing.contains(url) {\n                relays.append(Relay(url: url))\n                existing.insert(url)\n            }\n            if dependencies.activationAllowed() {\n                ensureConnections(to: Self.defaultRelays)\n            }\n        } else {\n            for url in Self.defaultRelays {\n                if let connection = connections[url] {\n                    connection.cancel(with: .goingAway, reason: nil)\n                }\n                connections.removeValue(forKey: url)\n                subscriptions.removeValue(forKey: url)\n                pendingSubscriptions.removeValue(forKey: url)\n            }\n            messageQueueLock.lock()\n            for index in (0..<messageQueue.count).reversed() {\n                var item = messageQueue[index]\n                item.pendingRelays.subtract(Self.defaultRelaySet)\n                if item.pendingRelays.isEmpty {\n                    messageQueue.remove(at: index)\n                } else {\n                    messageQueue[index] = item\n                }\n            }\n            messageQueueLock.unlock()\n            relays.removeAll { Self.defaultRelaySet.contains($0.url) }\n            updateConnectionStatus()\n        }\n    }\n\n    private func allowedRelayList(from urls: [String]) -> [String] {\n        var seen = Set<String>()\n        var result: [String] = []\n        for url in urls {\n            if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }\n            if seen.insert(url).inserted {\n                result.append(url)\n            }\n        }\n        return result\n    }\n    \n    /// Unsubscribe from a subscription\n    func unsubscribe(id: String) {\n        messageHandlers.removeValue(forKey: id)\n        // Allow immediate re-subscription by clearing coalescer timestamp\n        subscribeCoalesce.removeValue(forKey: id)\n        \n        let req = NostrRequest.close(id: id)\n        let message = try? encoder.encode(req)\n        \n        guard let messageData = message,\n              let messageString = String(data: messageData, encoding: .utf8) else { return }\n        \n        // Send unsubscribe to all relays\n        for (relayUrl, connection) in connections {\n            if subscriptions[relayUrl]?.contains(id) == true {\n                subscriptions[relayUrl]?.remove(id)\n                connection.send(.string(messageString)) { _ in\n                    // Local state is cleared before sending so callers can re-subscribe immediately.\n                }\n            }\n        }\n    }\n    \n    // MARK: - Private Methods\n    \n    private func connectToRelay(_ urlString: String) {\n        // Global network policy gate\n        guard dependencies.activationAllowed() else { return }\n        guard let url = URL(string: urlString) else { \n            SecureLogger.warning(\"Invalid relay URL: \\(urlString)\", category: .session)\n            return \n        }\n\n        // Avoid initiating connections while app is backgrounded; we'll reconnect on foreground\n        if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {\n            return\n        }\n        \n        // Skip if we already have a connection object\n        if connections[urlString] != nil {\n            return\n        }\n        if isPermanentlyFailed(urlString) {\n            return\n        }\n        \n        // Attempting to connect to Nostr relay via the proxied session\n        \n        // If Tor is enforced but not ready, delay connection until it is.\n        if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {\n            dependencies.awaitTorReady { [weak self] ready in\n                guard let self = self else { return }\n                if ready { self.connectToRelay(urlString) }\n                else { SecureLogger.error(\"❌ Tor not ready; skipping connection to \\(urlString)\", category: .session) }\n            }\n            return\n        }\n        \n        let session = dependencies.makeSession()\n        let task = session.webSocketTask(with: url)\n        \n        connections[urlString] = task\n        task.resume()\n        \n        // Start receiving messages\n        receiveMessage(from: task, relayUrl: urlString)\n        \n        // Send initial ping to verify connection\n        task.sendPing { [weak self] error in\n            DispatchQueue.main.async {\n                if error == nil {\n                    SecureLogger.debug(\"✅ Connected to Nostr relay: \\(urlString)\", category: .session)\n                    self?.updateRelayStatus(urlString, isConnected: true)\n                    // Flush any pending subscriptions for this relay\n                    self?.flushPendingSubscriptions(for: urlString)\n                } else {\n                    SecureLogger.error(\"❌ Failed to connect to Nostr relay \\(urlString): \\(error?.localizedDescription ?? \"Unknown error\")\", category: .session)\n                    self?.updateRelayStatus(urlString, isConnected: false, error: error)\n                    // Trigger disconnection handler for proper backoff\n                    self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: \"NostrRelay\", code: -1, userInfo: nil))\n                }\n            }\n        }\n    }\n\n    /// Send any queued subscriptions for a relay that just connected.\n    private func flushPendingSubscriptions(for relayUrl: String) {\n        guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }\n        guard let connection = connections[relayUrl] else { return }\n        for (id, messageString) in map {\n            if self.subscriptions[relayUrl]?.contains(id) == true { continue }\n            connection.send(.string(messageString)) { error in\n                if let error = error {\n                    SecureLogger.error(\"❌ Failed to send pending subscription to \\(relayUrl): \\(error)\", category: .session)\n                } else {\n                    Task { @MainActor in\n                        var subs = self.subscriptions[relayUrl] ?? Set<String>()\n                        subs.insert(id)\n                        self.subscriptions[relayUrl] = subs\n                    }\n                }\n            }\n        }\n        pendingSubscriptions[relayUrl] = nil\n    }\n    \n    private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {\n        task.receive { [weak self] result in\n            guard let self = self else { return }\n            \n            switch result {\n            case .success(let message):\n                // Parse off-main to reduce UI jank, then hop back for state updates\n                Task.detached(priority: .utility) {\n                    guard let parsed = ParsedInbound(message) else { return }\n                    await MainActor.run {\n                        self.handleParsedMessage(parsed, from: relayUrl)\n                    }\n                }\n                \n                // Continue receiving\n                Task { @MainActor in\n                    self.receiveMessage(from: task, relayUrl: relayUrl)\n                }\n                \n            case .failure(let error):\n                DispatchQueue.main.async {\n                    self.handleDisconnection(relayUrl: relayUrl, error: error)\n                }\n            }\n        }\n    }\n    \n    // Parsed inbound message type (off-main)\n    // Note: declared at file scope below to avoid MainActor isolation inside this class\n    // and keep parsing off the main actor.\n\n    // Handle parsed message on MainActor (state updates and handlers)\n    private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {\n        switch parsed {\n        case .event(let subId, let event):\n            if event.kind != 1059 {\n                SecureLogger.debug(\"📥 Event kind=\\(event.kind) id=\\(event.id.prefix(16))… relay=\\(relayUrl)\", category: .session)\n            }\n            if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {\n                self.relays[index].messagesReceived += 1\n            }\n            if let handler = self.messageHandlers[subId] {\n                handler(event)\n            } else {\n                SecureLogger.warning(\"⚠️ No handler for subscription \\(subId)\", category: .session)\n            }\n        case .eose(let subId):\n            if var tracker = eoseTrackers[subId] {\n                tracker.pendingRelays.remove(relayUrl)\n                if tracker.pendingRelays.isEmpty {\n                    tracker.timer?.invalidate()\n                    eoseTrackers.removeValue(forKey: subId)\n                    tracker.callback()\n                } else {\n                    eoseTrackers[subId] = tracker\n                }\n            }\n        case .ok(let eventId, let success, let reason):\n            if success {\n                _ = Self.pendingGiftWrapIDs.remove(eventId)\n                SecureLogger.debug(\"✅ Accepted id=\\(eventId.prefix(16))… relay=\\(relayUrl)\", category: .session)\n            } else {\n                let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil\n                if isGiftWrap {\n                    SecureLogger.warning(\"📮 Rejected id=\\(eventId.prefix(16))… reason=\\(reason)\", category: .session)\n                } else {\n                    SecureLogger.error(\"📮 Rejected id=\\(eventId.prefix(16))… reason=\\(reason)\", category: .session)\n                }\n            }\n        case .notice:\n            break\n        }\n    }\n    \n    private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {\n        let req = NostrRequest.event(event)\n        \n        do {\n            let data = try encoder.encode(req)\n            let message = String(data: data, encoding: .utf8) ?? \"\"\n            \n            SecureLogger.debug(\"📤 Send kind=\\(event.kind) id=\\(event.id.prefix(16))… relay=\\(relayUrl)\", category: .session)\n            \n            connection.send(.string(message)) { [weak self] error in\n                DispatchQueue.main.async {\n                    if let error = error {\n                        SecureLogger.error(\"❌ Failed to send event to \\(relayUrl): \\(error)\", category: .session)\n                    } else {\n                        // SecureLogger.debug(\"✅ Event sent to relay: \\(relayUrl)\", category: .session)\n                        // Update relay stats\n                        if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {\n                            self?.relays[index].messagesSent += 1\n                        }\n                    }\n                }\n            }\n        } catch {\n            SecureLogger.error(\"Failed to encode event: \\(error)\", category: .session)\n        }\n    }\n    \n    private func updateRelayStatus(_ url: String, isConnected: Bool, error: Error? = nil) {\n        if let index = relays.firstIndex(where: { $0.url == url }) {\n            relays[index].isConnected = isConnected\n            relays[index].lastError = error\n            if isConnected {\n                relays[index].lastConnectedAt = dependencies.now()\n                relays[index].reconnectAttempts = 0  // Reset on successful connection\n                relays[index].nextReconnectTime = nil\n            } else {\n                relays[index].lastDisconnectedAt = dependencies.now()\n            }\n        }\n        updateConnectionStatus()\n        // If we just connected to this relay, flush any queued sends targeting it\n        if isConnected {\n            flushMessageQueue(for: url)\n        }\n    }\n    \n    private func updateConnectionStatus() {\n        isConnected = relays.contains { $0.isConnected }\n    }\n    \n    private func handleDisconnection(relayUrl: String, error: Error) {\n        // If networking is disallowed, do not schedule reconnection\n        if !dependencies.activationAllowed() {\n            connections.removeValue(forKey: relayUrl)\n            subscriptions.removeValue(forKey: relayUrl)\n            updateRelayStatus(relayUrl, isConnected: false, error: error)\n            return\n        }\n        connections.removeValue(forKey: relayUrl)\n        subscriptions.removeValue(forKey: relayUrl)\n        updateRelayStatus(relayUrl, isConnected: false, error: error)\n        \n        // Check if this is a DNS or handshake error; treat as permanent\n        let errorDescription = error.localizedDescription.lowercased()\n        let ns = error as NSError\n        if errorDescription.contains(\"hostname could not be found\") || \n           errorDescription.contains(\"dns\") ||\n           (ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {\n            if relays.first(where: { $0.url == relayUrl })?.lastError == nil {\n                SecureLogger.warning(\"Nostr relay permanent failure for \\(relayUrl) - not retrying (code=\\(ns.code))\", category: .session)\n            }\n            if let index = relays.firstIndex(where: { $0.url == relayUrl }) {\n                relays[index].lastError = error\n                relays[index].reconnectAttempts = maxReconnectAttempts\n                relays[index].nextReconnectTime = nil\n            }\n            pendingSubscriptions[relayUrl] = nil\n            return\n        }\n        \n        // Implement exponential backoff for non-DNS errors\n        guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }\n        \n        relays[index].reconnectAttempts += 1\n        \n        // Stop attempting after max attempts\n        if relays[index].reconnectAttempts >= maxReconnectAttempts {\n            SecureLogger.warning(\"Max reconnection attempts (\\(maxReconnectAttempts)) reached for \\(relayUrl)\", category: .session)\n            return\n        }\n        \n        // Calculate backoff interval\n        let backoffInterval = min(\n            initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)),\n            maxBackoffInterval\n        )\n        \n        let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)\n        relays[index].nextReconnectTime = nextReconnectTime\n        \n        \n        // Schedule reconnection with exponential backoff\n        let gen = connectionGeneration\n        dependencies.scheduleAfter(backoffInterval) { [weak self] in\n            Task { @MainActor [weak self] in\n                guard let self = self else { return }\n                // Ignore stale scheduled reconnects from a previous generation\n                guard gen == self.connectionGeneration else { return }\n                // Check if we should still reconnect (relay might have been removed)\n                if self.relays.contains(where: { $0.url == relayUrl }) {\n                    self.connectToRelay(relayUrl)\n                }\n            }\n        }\n    }\n    \n    // MARK: - Public Utility Methods\n    \n    /// Manually retry connection to a specific relay\n    func retryConnection(to relayUrl: String) {\n        guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }\n        \n        // Reset reconnection attempts\n        relays[index].reconnectAttempts = 0\n        relays[index].nextReconnectTime = nil\n        relays[index].lastError = nil\n        \n        // Disconnect if connected\n        if let connection = connections[relayUrl] {\n            connection.cancel(with: .goingAway, reason: nil)\n            connections.removeValue(forKey: relayUrl)\n        }\n        \n        // Attempt immediate reconnection\n        connectToRelay(relayUrl)\n    }\n    \n    /// Get detailed status for all relays\n    func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] {\n        return relays.map { relay in\n            (url: relay.url, \n             isConnected: relay.isConnected, \n             reconnectAttempts: relay.reconnectAttempts,\n             nextReconnectTime: relay.nextReconnectTime)\n        }\n    }\n\n    var debugPendingMessageQueueCount: Int {\n        messageQueueLock.lock()\n        defer { messageQueueLock.unlock() }\n        return messageQueue.count\n    }\n\n    func debugPendingSubscriptionCount(for relayUrl: String) -> Int {\n        pendingSubscriptions[relayUrl]?.count ?? 0\n    }\n\n    func debugFlushMessageQueue() {\n        flushMessageQueue(for: nil)\n    }\n    \n    /// Reset all relay connections\n    func resetAllConnections() {\n        disconnect()\n        // New generation begins now\n        connectionGeneration &+= 1\n        \n        // Reset all relay states\n        for index in relays.indices {\n            relays[index].reconnectAttempts = 0\n            relays[index].nextReconnectTime = nil\n            relays[index].lastError = nil\n        }\n        \n        // Reconnect\n        connect()\n    }\n\n    // MARK: - Failure classification\n    private func isPermanentlyFailed(_ url: String) -> Bool {\n        guard let r = relays.first(where: { $0.url == url }) else { return false }\n        if r.reconnectAttempts >= maxReconnectAttempts { return true }\n        if let ns = r.lastError as NSError?, ns.domain == NSURLErrorDomain {\n            if ns.code == NSURLErrorBadServerResponse || ns.code == NSURLErrorCannotFindHost {\n                return true\n            }\n        }\n        return false\n    }\n}\n\n// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)\n\nprivate enum ParsedInbound {\n    case event(subId: String, event: NostrEvent)\n    case ok(eventId: String, success: Bool, reason: String)\n    case eose(subscriptionId: String)\n    case notice(String)\n    \n    init?(_ message: URLSessionWebSocketTask.Message) {\n        guard let data = message.data,\n              let array = try? JSONSerialization.jsonObject(with: data) as? [Any],\n              array.count >= 2,\n              let type = array[0] as? String else {\n            return nil\n        }\n\n        switch type {\n        case \"EVENT\":\n            if array.count >= 3,\n               let subId = array[1] as? String,\n               let eventDict = array[2] as? [String: Any],\n               let event = try? NostrEvent(from: eventDict),\n               event.isValidSignature() {\n                self = .event(subId: subId, event: event)\n                return\n            }\n            return nil\n        case \"EOSE\":\n            if let subId = array[1] as? String {\n                self = .eose(subscriptionId: subId)\n                return\n            }\n            return nil\n        case \"OK\":\n            if array.count >= 3,\n               let eventId = array[1] as? String,\n               let success = array[2] as? Bool {\n                let reason = array.count >= 4 ? (array[3] as? String ?? \"no reason given\") : \"no reason given\"\n                self = .ok(eventId: eventId, success: success, reason: reason)\n                return\n            }\n            return nil\n        case \"NOTICE\":\n            if array.count >= 2, let msg = array[1] as? String {\n                self = .notice(msg)\n                return\n            }\n            return nil\n        default:\n            return nil\n        }\n    }\n}\n\nprivate extension URLSessionWebSocketTask.Message {\n    var data: Data? {\n        switch self {\n        case .string(let text): text.data(using: .utf8)\n        case .data(let data):   data\n        @unknown default:       nil\n        }\n    }\n}\n\n// MARK: - Nostr Protocol Types\n\nenum NostrRequest: Encodable {\n    case event(NostrEvent)\n    case subscribe(id: String, filters: [NostrFilter])\n    case close(id: String)\n    \n    func encode(to encoder: Encoder) throws {\n        var container = encoder.unkeyedContainer()\n        \n        switch self {\n        case .event(let event):\n            try container.encode(\"EVENT\")\n            try container.encode(event)\n            \n        case .subscribe(let id, let filters):\n            try container.encode(\"REQ\")\n            try container.encode(id)\n            for filter in filters {\n                try container.encode(filter)\n            }\n            \n        case .close(let id):\n            try container.encode(\"CLOSE\")\n            try container.encode(id)\n        }\n    }\n}\n\nstruct NostrFilter: Encodable {\n    var ids: [String]?\n    var authors: [String]?\n    var kinds: [Int]?\n    var since: Int?\n    var until: Int?\n    var limit: Int?\n    \n    // Tag filters - stored internally but encoded specially\n    fileprivate var tagFilters: [String: [String]]?\n    \n    init() {\n        // Default initializer\n    }\n    \n    // Custom encoding to handle tag filters properly\n    enum CodingKeys: String, CodingKey {\n        case ids, authors, kinds, since, until, limit\n    }\n    \n    func encode(to encoder: Encoder) throws {\n        var container = encoder.container(keyedBy: DynamicCodingKey.self)\n        \n        // Encode standard fields\n        if let ids = ids { try container.encode(ids, forKey: DynamicCodingKey(stringValue: \"ids\")) }\n        if let authors = authors { try container.encode(authors, forKey: DynamicCodingKey(stringValue: \"authors\")) }\n        if let kinds = kinds { try container.encode(kinds, forKey: DynamicCodingKey(stringValue: \"kinds\")) }\n        if let since = since { try container.encode(since, forKey: DynamicCodingKey(stringValue: \"since\")) }\n        if let until = until { try container.encode(until, forKey: DynamicCodingKey(stringValue: \"until\")) }\n        if let limit = limit { try container.encode(limit, forKey: DynamicCodingKey(stringValue: \"limit\")) }\n        \n        // Encode tag filters with # prefix\n        if let tagFilters = tagFilters {\n            for (tag, values) in tagFilters {\n                try container.encode(values, forKey: DynamicCodingKey(stringValue: \"#\\(tag)\"))\n            }\n        }\n    }\n    \n    // For NIP-17 gift wraps\n    static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {\n        var filter = NostrFilter()\n        filter.kinds = [1059] // Gift wrap kind\n        filter.since = since?.timeIntervalSince1970.toInt()\n        filter.tagFilters = [\"p\": [pubkey]]\n        filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit\n        return filter\n    }\n\n    // For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)\n    static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {\n        var filter = NostrFilter()\n        filter.kinds = [20000, 20001]\n        filter.since = since?.timeIntervalSince1970.toInt()\n        filter.tagFilters = [\"g\": [geohash]]\n        filter.limit = limit\n        return filter\n    }\n\n    // For location notes: persistent text notes (kind 1) tagged with geohash\n    static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {\n        var filter = NostrFilter()\n        filter.kinds = [1]\n        filter.since = since?.timeIntervalSince1970.toInt()\n        filter.tagFilters = [\"g\": [geohash]]\n        filter.limit = limit\n        return filter\n    }\n\n    // For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)\n    static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {\n        var filter = NostrFilter()\n        filter.kinds = [1]\n        filter.since = since?.timeIntervalSince1970.toInt()\n        filter.tagFilters = [\"g\": geohashes]\n        filter.limit = limit\n        return filter\n    }\n}\n\n// Dynamic coding key for tag filters\nprivate struct DynamicCodingKey: CodingKey {\n    var stringValue: String\n    var intValue: Int? { nil }\n    \n    init(stringValue: String) {\n        self.stringValue = stringValue\n    }\n    \n    init?(intValue: Int) {\n        return nil\n    }\n}\n\nprivate extension TimeInterval {\n    func toInt() -> Int {\n        return Int(self)\n    }\n}\n"
  },
  {
    "path": "bitchat/Nostr/XChaCha20Poly1305Compat.swift",
    "content": "import Foundation\nimport CryptoKit\n\n/// Minimal XChaCha20-Poly1305 compatibility wrapper using CryptoKit's ChaChaPoly.\n/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce\n/// as per XChaCha20 construction.\nenum XChaCha20Poly1305Compat {\n\n    /// Errors that can occur during XChaCha20-Poly1305 operations\n    enum Error: Swift.Error {\n        case invalidKeyLength(expected: Int, got: Int)\n        case invalidNonceLength(expected: Int, got: Int)\n    }\n\n    struct SealBox {\n        let ciphertext: Data\n        let tag: Data\n    }\n\n    static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {\n        guard key.count == 32 else {\n            throw Error.invalidKeyLength(expected: 32, got: key.count)\n        }\n        guard nonce24.count == 24 else {\n            throw Error.invalidNonceLength(expected: 24, got: nonce24.count)\n        }\n\n        let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))\n        let nonce12 = derive12ByteNonce(from24: nonce24)\n        let chachaKey = SymmetricKey(data: subkey)\n        let nonce = try ChaChaPoly.Nonce(data: nonce12)\n        let sealed = try ChaChaPoly.seal(plaintext, using: chachaKey, nonce: nonce, authenticating: aad ?? Data())\n        return SealBox(ciphertext: sealed.ciphertext, tag: sealed.tag)\n    }\n\n    static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {\n        guard key.count == 32 else {\n            throw Error.invalidKeyLength(expected: 32, got: key.count)\n        }\n        guard nonce24.count == 24 else {\n            throw Error.invalidNonceLength(expected: 24, got: nonce24.count)\n        }\n\n        let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))\n        let nonce12 = derive12ByteNonce(from24: nonce24)\n        let chachaKey = SymmetricKey(data: subkey)\n        let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)\n        return try ChaChaPoly.open(box, using: chachaKey, authenticating: aad ?? Data())\n    }\n\n    // MARK: - Internals\n\n    private static func derive12ByteNonce(from24 nonce24: Data) -> Data {\n        // XChaCha20-Poly1305: 12-byte nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce\n        var out = Data(count: 12)\n        out.replaceSubrange(0..<4, with: [0, 0, 0, 0])\n        out.replaceSubrange(4..<12, with: nonce24.suffix(8))\n        return out\n    }\n\n    private static func hchacha20(key: Data, nonce16: Data) throws -> Data {\n        // HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.\n        guard key.count == 32 else {\n            throw Error.invalidKeyLength(expected: 32, got: key.count)\n        }\n        guard nonce16.count == 16 else {\n            throw Error.invalidNonceLength(expected: 16, got: nonce16.count)\n        }\n\n        // Constants \"expand 32-byte k\"\n        var state: [UInt32] = [\n            0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,\n            // key (8 words)\n            key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),\n            key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),\n            // nonce (4 words)\n            nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)\n        ]\n\n        // 20 rounds (10 double rounds)\n        for _ in 0..<10 {\n            // Column rounds\n            quarterRound(&state, 0, 4, 8, 12)\n            quarterRound(&state, 1, 5, 9, 13)\n            quarterRound(&state, 2, 6, 10, 14)\n            quarterRound(&state, 3, 7, 11, 15)\n            // Diagonal rounds\n            quarterRound(&state, 0, 5, 10, 15)\n            quarterRound(&state, 1, 6, 11, 12)\n            quarterRound(&state, 2, 7, 8, 13)\n            quarterRound(&state, 3, 4, 9, 14)\n        }\n\n        // Output subkey: state[0..3] and state[12..15]\n        var out = Data(count: 32)\n        out.storeLEWord(state[0], at: 0)\n        out.storeLEWord(state[1], at: 4)\n        out.storeLEWord(state[2], at: 8)\n        out.storeLEWord(state[3], at: 12)\n        out.storeLEWord(state[12], at: 16)\n        out.storeLEWord(state[13], at: 20)\n        out.storeLEWord(state[14], at: 24)\n        out.storeLEWord(state[15], at: 28)\n        return out\n    }\n\n    private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {\n        s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 16) | (s[d] >> 16)\n        s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 12) | (s[b] >> 20)\n        s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 8)  | (s[d] >> 24)\n        s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 7)  | (s[b] >> 25)\n    }\n}\n\nprivate extension Data {\n    func loadLEWord(_ offset: Int) -> UInt32 {\n        let range = offset..<(offset+4)\n        let bytes = self[range]\n        return bytes.withUnsafeBytes { ptr -> UInt32 in\n            let b = ptr.bindMemory(to: UInt8.self)\n            return UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)\n        }\n    }\n\n    mutating func storeLEWord(_ value: UInt32, at offset: Int) {\n        let bytes: [UInt8] = [\n            UInt8(value & 0xff),\n            UInt8((value >> 8) & 0xff),\n            UInt8((value >> 16) & 0xff),\n            UInt8((value >> 24) & 0xff)\n        ]\n        replaceSubrange(offset..<(offset+4), with: bytes)\n    }\n}\n\n"
  },
  {
    "path": "bitchat/Protocols/BinaryEncodingUtils.swift",
    "content": "//\n// BinaryEncodingUtils.swift\n// bitchat\n//\n// Binary encoding utilities for efficient protocol messages\n//\n\nimport Foundation\nimport CryptoKit\n\n// MARK: - Hex Encoding/Decoding\n\nextension Data {\n    func hexEncodedString() -> String {\n        if self.isEmpty {\n            return \"\"\n        }\n        return self.map { String(format: \"%02x\", $0) }.joined()\n    }\n\n    func sha256Hex() -> String {\n        let digest = SHA256.hash(data: self)\n        return digest.map { String(format: \"%02x\", $0) }.joined()\n    }\n    \n    /// Initialize Data from a hex string.\n    /// - Parameter hexString: A hex string, optionally prefixed with \"0x\" or \"0X\".\n    ///   Whitespace is trimmed. Must have even length after prefix removal.\n    /// - Returns: nil if the string has odd length or contains invalid hex characters.\n    init?(hexString: String) {\n        var hex = hexString.trimmingCharacters(in: .whitespaces)\n\n        // Remove optional 0x prefix\n        if hex.hasPrefix(\"0x\") || hex.hasPrefix(\"0X\") {\n            hex = String(hex.dropFirst(2))\n        }\n\n        // Reject odd-length strings\n        guard hex.count % 2 == 0 else {\n            return nil\n        }\n\n        // Reject empty strings\n        guard !hex.isEmpty else {\n            self = Data()\n            return\n        }\n\n        let len = hex.count / 2\n        var data = Data(capacity: len)\n        var index = hex.startIndex\n\n        for _ in 0..<len {\n            let nextIndex = hex.index(index, offsetBy: 2)\n            guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {\n                return nil\n            }\n            data.append(byte)\n            index = nextIndex\n        }\n\n        self = data\n    }\n}\n\n// MARK: - Binary Encoding Utilities\n\nextension Data {\n    // MARK: Writing\n    \n    @inlinable mutating func appendUInt8(_ value: UInt8) {\n        self.append(value)\n    }\n    \n    @inlinable mutating func appendUInt16(_ value: UInt16) {\n        self.append(UInt8((value >> 8) & 0xFF))\n        self.append(UInt8(value & 0xFF))\n    }\n    \n    @inlinable mutating func appendUInt32(_ value: UInt32) {\n        self.append(UInt8((value >> 24) & 0xFF))\n        self.append(UInt8((value >> 16) & 0xFF))\n        self.append(UInt8((value >> 8) & 0xFF))\n        self.append(UInt8(value & 0xFF))\n    }\n    \n    @inlinable mutating func appendUInt64(_ value: UInt64) {\n        for i in (0..<8).reversed() {\n            self.append(UInt8((value >> (i * 8)) & 0xFF))\n        }\n    }\n    \n    mutating func appendString(_ string: String, maxLength: Int = 255) {\n        guard let data = string.data(using: .utf8) else { return }\n        let length = Swift.min(data.count, maxLength)\n        \n        if maxLength <= 255 {\n            self.append(UInt8(length))\n        } else {\n            self.appendUInt16(UInt16(length))\n        }\n        \n        self.append(data.prefix(length))\n    }\n    \n    mutating func appendData(_ data: Data, maxLength: Int = 65535) {\n        let length = Swift.min(data.count, maxLength)\n        \n        if maxLength <= 255 {\n            self.append(UInt8(length))\n        } else {\n            self.appendUInt16(UInt16(length))\n        }\n        \n        self.append(data.prefix(length))\n    }\n    \n    mutating func appendDate(_ date: Date) {\n        let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds\n        self.appendUInt64(timestamp)\n    }\n    \n    mutating func appendUUID(_ uuid: String) {\n        // Convert UUID string to 16 bytes\n        var uuidData = Data(count: 16)\n        \n        let cleanUUID = uuid.replacingOccurrences(of: \"-\", with: \"\")\n        var index = cleanUUID.startIndex\n        \n        for i in 0..<16 {\n            guard index < cleanUUID.endIndex else { break }\n            let nextIndex = cleanUUID.index(index, offsetBy: 2)\n            if let byte = UInt8(String(cleanUUID[index..<nextIndex]), radix: 16) {\n                uuidData[i] = byte\n            }\n            index = nextIndex\n        }\n        \n        self.append(uuidData)\n    }\n    \n    // MARK: Reading\n    \n    @inlinable func readUInt8(at offset: inout Int) -> UInt8? {\n        guard offset >= 0 && offset < self.count else { return nil }\n        let value = self[offset]\n        offset += 1\n        return value\n    }\n    \n    @inlinable func readUInt16(at offset: inout Int) -> UInt16? {\n        guard offset + 2 <= self.count else { return nil }\n        let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1])\n        offset += 2\n        return value\n    }\n    \n    @inlinable func readUInt32(at offset: inout Int) -> UInt32? {\n        guard offset + 4 <= self.count else { return nil }\n        let value = UInt32(self[offset]) << 24 |\n                   UInt32(self[offset + 1]) << 16 |\n                   UInt32(self[offset + 2]) << 8 |\n                   UInt32(self[offset + 3])\n        offset += 4\n        return value\n    }\n    \n    @inlinable func readUInt64(at offset: inout Int) -> UInt64? {\n        guard offset + 8 <= self.count else { return nil }\n        var value: UInt64 = 0\n        for i in 0..<8 {\n            value = (value << 8) | UInt64(self[offset + i])\n        }\n        offset += 8\n        return value\n    }\n    \n    func readString(at offset: inout Int, maxLength: Int = 255) -> String? {\n        let length: Int\n        \n        if maxLength <= 255 {\n            guard let len = readUInt8(at: &offset) else { return nil }\n            length = Int(len)\n        } else {\n            guard let len = readUInt16(at: &offset) else { return nil }\n            length = Int(len)\n        }\n        \n        guard offset + length <= self.count else { return nil }\n        \n        let stringData = self[offset..<offset + length]\n        offset += length\n        \n        return String(data: stringData, encoding: .utf8)\n    }\n    \n    func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {\n        let length: Int\n        \n        if maxLength <= 255 {\n            guard let len = readUInt8(at: &offset) else { return nil }\n            length = Int(len)\n        } else {\n            guard let len = readUInt16(at: &offset) else { return nil }\n            length = Int(len)\n        }\n        \n        guard offset + length <= self.count else { return nil }\n        \n        let data = self[offset..<offset + length]\n        offset += length\n        \n        return data\n    }\n    \n    func readDate(at offset: inout Int) -> Date? {\n        guard let timestamp = readUInt64(at: &offset) else { return nil }\n        return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)\n    }\n    \n    func readUUID(at offset: inout Int) -> String? {\n        guard offset + 16 <= self.count else { return nil }\n        \n        let uuidData = self[offset..<offset + 16]\n        offset += 16\n        \n        // Convert 16 bytes to UUID string format\n        let uuid = uuidData.hexEncodedString()\n        \n        // Insert hyphens at proper positions: 8-4-4-4-12\n        var result = \"\"\n        for (index, char) in uuid.enumerated() {\n            if index == 8 || index == 12 || index == 16 || index == 20 {\n                result += \"-\"\n            }\n            result.append(char)\n        }\n        \n        return result.uppercased()\n    }\n    \n    func readFixedBytes(at offset: inout Int, count: Int) -> Data? {\n        guard offset + count <= self.count else { return nil }\n        \n        let data = self[offset..<offset + count]\n        offset += count\n        \n        return data\n    }\n}\n"
  },
  {
    "path": "bitchat/Protocols/BinaryProtocol.swift",
    "content": "//\n// BinaryProtocol.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\n///\n/// # BinaryProtocol\n///\n/// Low-level binary encoding and decoding for BitChat protocol messages.\n/// Optimized for Bluetooth LE's limited bandwidth and MTU constraints.\n///\n/// ## Overview\n/// BinaryProtocol implements an efficient binary wire format that minimizes\n/// overhead while maintaining extensibility. It handles:\n/// - Compact binary encoding with fixed headers\n/// - Optional field support via flags\n/// - Automatic compression for large payloads\n/// - Endianness handling for cross-platform compatibility\n///\n/// ## Wire Format\n/// ```\n/// Header (Fixed 14 bytes for v1, 16 bytes for v2):\n/// +--------+------+-----+-----------+-------+------------------+\n/// |Version | Type | TTL | Timestamp | Flags | PayloadLength    |\n/// |1 byte  |1 byte|1byte| 8 bytes   | 1 byte| 2 or 4 bytes     |\n/// +--------+------+-----+-----------+-------+------------------+\n///\n/// Variable sections:\n/// +----------+-------------+---------+------------+\n/// | SenderID | RecipientID | Payload | Signature  |\n/// | 8 bytes  | 8 bytes*    | Variable| 64 bytes*  |\n/// +----------+-------------+---------+------------+\n/// * Optional fields based on flags\n/// ```\n///\n/// ## Design Rationale\n/// The protocol is designed for:\n/// - **Efficiency**: Minimal overhead for small messages\n/// - **Flexibility**: Optional fields via flag bits\n/// - **Compatibility**: Network byte order (big-endian)\n/// - **Performance**: Zero-copy where possible\n///\n/// ## Compression Strategy\n/// - Automatic compression for payloads > 256 bytes\n/// - zlib compression for broad compatibility on Apple platforms\n/// - Original size stored for decompression\n/// - Flag bit indicates compressed payload\n///\n/// ## Flag Bits\n/// - Bit 0: Has recipient ID (directed message)\n/// - Bit 1: Has signature (authenticated message)\n/// - Bit 2: Is compressed (zlib compression applied)\n/// - Bits 3-7: Reserved for future use\n///\n/// ## Size Constraints\n/// - Maximum packet size: 65,535 bytes (16-bit length field)\n/// - Typical packet size: < 512 bytes (BLE MTU)\n/// - Minimum packet size: 21 bytes (header + sender ID)\n///\n/// ## Encoding Process\n/// 1. Construct header with fixed fields\n/// 2. Set appropriate flags\n/// 3. Compress payload if beneficial\n/// 4. Append variable-length fields\n/// 5. Calculate and append signature if needed\n///\n/// ## Decoding Process\n/// 1. Validate minimum packet size\n/// 2. Parse fixed header\n/// 3. Extract flags and determine field presence\n/// 4. Parse variable fields based on flags\n/// 5. Decompress payload if compressed\n/// 6. Verify signature if present\n///\n/// ## Error Handling\n/// - Graceful handling of malformed packets\n/// - Clear error messages for debugging\n/// - No crashes on invalid input\n/// - Logging of protocol violations\n///\n/// ## Performance Notes\n/// - Allocation-free for small messages\n/// - Streaming support for large payloads\n/// - Efficient bit manipulation\n/// - Platform-optimized byte swapping\n///\n\nimport Foundation\nimport BitLogger\n\nextension Data {\n    func trimmingNullBytes() -> Data {\n        // Find the first null byte\n        if let nullIndex = self.firstIndex(of: 0) {\n            return self.prefix(nullIndex)\n        }\n        return self\n    }\n}\n\n/// Implements binary encoding and decoding for BitChat protocol messages.\n/// Provides static methods for converting between BitchatPacket objects and\n/// their binary wire format representation.\n/// - Note: All multi-byte values use network byte order (big-endian)\nstruct BinaryProtocol {\n    static let v1HeaderSize = 14\n    static let v2HeaderSize = 16\n    static let senderIDSize = 8\n    static let recipientIDSize = 8\n    static let signatureSize = 64\n\n    // Field offsets within packet header\n    struct Offsets {\n        static let version = 0\n        static let type = 1\n        static let ttl = 2\n        static let timestamp = 3\n        static let flags = 11  // After version(1) + type(1) + ttl(1) + timestamp(8)\n    }\n\n    static func headerSize(for version: UInt8) -> Int? {\n        switch version {\n        case 1: return v1HeaderSize\n        case 2: return v2HeaderSize\n        default: return nil\n        }\n    }\n\n    private static func lengthFieldSize(for version: UInt8) -> Int {\n        return version == 2 ? 4 : 2\n    }\n    \n    struct Flags {\n        static let hasRecipient: UInt8 = 0x01\n        static let hasSignature: UInt8 = 0x02\n        static let isCompressed: UInt8 = 0x04\n        static let hasRoute: UInt8 = 0x08\n        static let isRSR: UInt8 = 0x10\n    }\n    \n    // Encode BitchatPacket to binary format\n    static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {\n        let version = packet.version\n        guard version == 1 || version == 2 else { return nil }\n\n        // Try to compress payload when beneficial, keeping original size for later decoding\n        var payload = packet.payload\n        var isCompressed = false\n        var originalPayloadSize: Int?\n        if CompressionUtil.shouldCompress(payload) {\n            // Only compress when we can represent the original length in the outbound frame\n            let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max)\n            if payload.count <= maxRepresentable,\n               let compressedPayload = CompressionUtil.compress(payload) {\n                originalPayloadSize = payload.count\n                payload = compressedPayload\n                isCompressed = true\n            }\n        }\n\n        let lengthFieldBytes = lengthFieldSize(for: version)\n        \n        // Route is only supported for v2+ packets (per SOURCE_ROUTING.md spec)\n        let originalRoute = (version >= 2) ? (packet.route ?? []) : []\n        if originalRoute.contains(where: { $0.isEmpty }) { return nil }\n        let sanitizedRoute: [Data] = originalRoute.map { hop in\n            if hop.count == senderIDSize { return hop }\n            if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) }\n            var padded = hop\n            padded.append(Data(repeating: 0, count: senderIDSize - hop.count))\n            return padded\n        }\n        guard sanitizedRoute.count <= 255 else { return nil }\n\n        let hasRoute = !sanitizedRoute.isEmpty\n        let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0\n        let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0\n        // payloadLength in header is payload-only (does NOT include route bytes)\n        let payloadDataSize = payload.count + originalSizeFieldBytes\n\n        if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }\n        if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }\n\n        guard let headerSize = headerSize(for: version) else { return nil }\n        let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + routeLength\n        let estimatedPayload = payloadDataSize\n        let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)\n        var data = Data()\n        data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255)\n\n        data.append(version)\n        data.append(packet.type)\n        data.append(packet.ttl)\n\n        for shift in stride(from: 56, through: 0, by: -8) {\n            data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF))\n        }\n\n        var flags: UInt8 = 0\n        if packet.recipientID != nil { flags |= Flags.hasRecipient }\n        if packet.signature != nil { flags |= Flags.hasSignature }\n        if isCompressed { flags |= Flags.isCompressed }\n        // HAS_ROUTE is only valid for v2+ packets\n        if hasRoute && version >= 2 { flags |= Flags.hasRoute }\n        if packet.isRSR { flags |= Flags.isRSR }\n        data.append(flags)\n        \n        if version == 2 {\n            let length = UInt32(payloadDataSize)\n            for shift in stride(from: 24, through: 0, by: -8) {\n                data.append(UInt8((length >> UInt32(shift)) & 0xFF))\n            }\n        } else {\n            let length = UInt16(payloadDataSize)\n            data.append(UInt8((length >> 8) & 0xFF))\n            data.append(UInt8(length & 0xFF))\n        }\n\n        let senderBytes = packet.senderID.prefix(senderIDSize)\n        data.append(senderBytes)\n        if senderBytes.count < senderIDSize {\n            data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))\n        }\n\n        if let recipientID = packet.recipientID {\n            let recipientBytes = recipientID.prefix(recipientIDSize)\n            data.append(recipientBytes)\n            if recipientBytes.count < recipientIDSize {\n                data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))\n            }\n        }\n\n        if hasRoute {\n            data.append(UInt8(sanitizedRoute.count))\n            for hop in sanitizedRoute {\n                data.append(hop)\n            }\n        }\n\n        if isCompressed, let originalSize = originalPayloadSize {\n            if version == 2 {\n                let value = UInt32(originalSize)\n                for shift in stride(from: 24, through: 0, by: -8) {\n                    data.append(UInt8((value >> UInt32(shift)) & 0xFF))\n                }\n            } else {\n                let value = UInt16(originalSize)\n                data.append(UInt8((value >> 8) & 0xFF))\n                data.append(UInt8(value & 0xFF))\n            }\n        }\n        data.append(payload)\n\n        if let signature = packet.signature {\n            data.append(signature.prefix(signatureSize))\n        }\n\n        if padding {\n            let optimalSize = MessagePadding.optimalBlockSize(for: data.count)\n            return MessagePadding.pad(data, toSize: optimalSize)\n        }\n        return data\n    }\n    \n    // Decode binary data to BitchatPacket\n    static func decode(_ data: Data) -> BitchatPacket? {\n        // Try decode as-is first (robust when padding wasn't applied)\n        if let pkt = decodeCore(data) { return pkt }\n        // If that fails, try after removing padding\n        let unpadded = MessagePadding.unpad(data)\n        if unpadded as NSData === data as NSData { return nil }\n        return decodeCore(unpadded)\n    }\n\n    // Core decoding implementation used by decode(_:) with and without padding removal\n    private static func decodeCore(_ raw: Data) -> BitchatPacket? {\n        guard raw.count >= v1HeaderSize + senderIDSize else { return nil }\n\n        return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in\n            guard let base = buf.baseAddress else { return nil }\n            var offset = 0\n            func require(_ n: Int) -> Bool { offset + n <= buf.count }\n            func read8() -> UInt8? {\n                guard require(1) else { return nil }\n                let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee\n                offset += 1\n                return value\n            }\n            func read16() -> UInt16? {\n                guard require(2) else { return nil }\n                let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)\n                let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1])\n                offset += 2\n                return value\n            }\n            func read32() -> UInt32? {\n                guard require(4) else { return nil }\n                let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)\n                let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3])\n                offset += 4\n                return value\n            }\n            func readData(_ n: Int) -> Data? {\n                guard require(n) else { return nil }\n                let ptr = base.advanced(by: offset)\n                let data = Data(bytes: ptr, count: n)\n                offset += n\n                return data\n            }\n\n            guard let version = read8(), version == 1 || version == 2 else { return nil }\n            let lengthFieldBytes = lengthFieldSize(for: version)\n            guard let headerSize = headerSize(for: version) else { return nil }\n            let minimumRequired = headerSize + senderIDSize\n            guard raw.count >= minimumRequired else { return nil }\n\n            guard let type = read8(), let ttl = read8() else { return nil }\n\n            var timestamp: UInt64 = 0\n            for _ in 0..<8 {\n                guard let byte = read8() else { return nil }\n                timestamp = (timestamp << 8) | UInt64(byte)\n            }\n\n            guard let flags = read8() else { return nil }\n            let hasRecipient = (flags & Flags.hasRecipient) != 0\n            let hasSignature = (flags & Flags.hasSignature) != 0\n            let isCompressed = (flags & Flags.isCompressed) != 0\n            // HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1\n            let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0\n            let isRSR = (flags & Flags.isRSR) != 0\n            \n            let payloadLength: Int\n            if version == 2 {\n                guard let len = read32() else { return nil }\n                payloadLength = Int(len)\n            } else {\n                guard let len = read16() else { return nil }\n                payloadLength = Int(len)\n            }\n\n            guard payloadLength >= 0 else { return nil }\n            guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil }\n\n            guard let senderID = readData(senderIDSize) else { return nil }\n\n            var recipientID: Data? = nil\n            if hasRecipient {\n                recipientID = readData(recipientIDSize)\n                if recipientID == nil { return nil }\n            }\n\n            // Route (optional, v2+ only): route bytes are NOT included in payloadLength\n            var route: [Data]? = nil\n            if hasRoute {\n                guard let routeCount = read8() else { return nil }\n                if routeCount > 0 {\n                    var hops: [Data] = []\n                    for _ in 0..<Int(routeCount) {\n                        guard let hop = readData(senderIDSize) else { return nil }\n                        hops.append(hop)\n                    }\n                    route = hops\n                }\n            }\n\n            // Payload: payloadLength is exactly the payload size (+ compression preamble if compressed)\n            let payload: Data\n            if isCompressed {\n                guard payloadLength >= lengthFieldBytes else { return nil }\n                let originalSize: Int\n                if version == 2 {\n                    guard let rawSize = read32() else { return nil }\n                    originalSize = Int(rawSize)\n                } else {\n                    guard let rawSize = read16() else { return nil }\n                    originalSize = Int(rawSize)\n                }\n                guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }\n                let compressedSize = payloadLength - lengthFieldBytes\n                guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }\n\n                let compressionRatio = Double(originalSize) / Double(compressedSize)\n                guard compressionRatio <= 50_000.0 else {\n                    SecureLogger.warning(\"🚫 Suspicious compression ratio: \\(String(format: \"%.0f\", compressionRatio)):1\", category: .security)\n                    return nil\n                }\n\n                guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),\n                      decompressed.count == originalSize else { return nil }\n                payload = decompressed\n            } else {\n                guard let rawPayload = readData(payloadLength) else { return nil }\n                payload = rawPayload\n            }\n\n            var signature: Data? = nil\n            if hasSignature {\n                signature = readData(signatureSize)\n                if signature == nil { return nil }\n            }\n\n            guard offset <= buf.count else { return nil }\n\n            return BitchatPacket(\n                type: type,\n                senderID: senderID,\n                recipientID: recipientID,\n                timestamp: timestamp,\n                payload: payload,\n                signature: signature,\n                ttl: ttl,\n                version: version,\n                route: route,\n                isRSR: isRSR\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Protocols/BitchatFilePacket.swift",
    "content": "//\n// BitchatFilePacket.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\nimport BitLogger\n\n/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).\n/// Mirrors the Android client specification to ensure cross-platform interoperability.\nstruct BitchatFilePacket {\n    var fileName: String?\n    var fileSize: UInt64?\n    var mimeType: String?\n    var content: Data\n\n    /// Canonical TLV tags defined by the Android implementation.\n    private enum TLVType: UInt8 {\n        case fileName = 0x01\n        case fileSize = 0x02\n        case mimeType = 0x03\n        case content = 0x04\n    }\n\n    /// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).\n    /// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).\n    func encode() -> Data? {\n        let resolvedSize = fileSize ?? UInt64(content.count)\n        guard resolvedSize <= UInt64(UInt32.max) else { return nil }\n        guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }\n        guard content.count <= Int(UInt32.max) else { return nil }\n        guard FileTransferLimits.isValidPayload(content.count) else { return nil }\n\n        func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {\n            var big = value.bigEndian\n            withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }\n        }\n\n        var encoded = Data()\n\n        if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {\n            encoded.append(TLVType.fileName.rawValue)\n            appendBE(UInt16(nameData.count), into: &encoded)\n            encoded.append(nameData)\n        }\n\n        encoded.append(TLVType.fileSize.rawValue)\n        appendBE(UInt16(4), into: &encoded)\n        appendBE(UInt32(resolvedSize), into: &encoded)\n\n        if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {\n            encoded.append(TLVType.mimeType.rawValue)\n            appendBE(UInt16(mimeData.count), into: &encoded)\n            encoded.append(mimeData)\n        }\n\n        encoded.append(TLVType.content.rawValue)\n        appendBE(UInt32(content.count), into: &encoded)\n        encoded.append(content)\n\n        return encoded\n    }\n\n    /// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.\n    static func decode(_ data: Data) -> BitchatFilePacket? {\n        var cursor = data.startIndex\n        let end = data.endIndex\n\n        var fileName: String?\n        var fileSize: UInt64?\n        var mimeType: String?\n        var content = Data()\n\n        while cursor < end {\n            let typeRaw = data[cursor]\n            cursor = data.index(after: cursor)\n\n            guard cursor <= end else { return nil }\n            let tlvType = TLVType(rawValue: typeRaw)\n\n            func readBigEndianLength(bytes: Int) -> Int? {\n                guard data.distance(from: cursor, to: end) >= bytes else { return nil }\n                // Use UInt64 to prevent integer overflow during shift operations\n                var result: UInt64 = 0\n                for _ in 0..<bytes {\n                    result = (result << 8) | UInt64(data[cursor])\n                    cursor = data.index(after: cursor)\n                }\n                // Safely convert to Int with overflow check\n                guard result <= Int.max else { return nil }\n                return Int(result)\n            }\n\n            let length: Int?\n            if tlvType == .content {\n                let snapshot = cursor\n                let canonical = readBigEndianLength(bytes: 4)\n                if let canonical = canonical,\n                   canonical <= data.distance(from: cursor, to: end) {\n                    length = canonical\n                } else {\n                    cursor = snapshot\n                    length = readBigEndianLength(bytes: 2)\n                }\n            } else {\n                length = readBigEndianLength(bytes: 2)\n            }\n\n            guard let tlvLength = length, tlvLength >= 0 else { return nil }\n            guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }\n\n            let valueStart = cursor\n            cursor = data.index(cursor, offsetBy: tlvLength)\n            let value = data[valueStart..<cursor]\n\n            switch tlvType {\n            case .fileName:\n                fileName = String(data: Data(value), encoding: .utf8)\n            case .fileSize:\n                if tlvLength == 4 || tlvLength == 8 {\n                    var size: UInt64 = 0\n                    for byte in value {\n                        size = (size << 8) | UInt64(byte)\n                    }\n                    if size > UInt64(FileTransferLimits.maxPayloadBytes) {\n                        return nil\n                    }\n                    fileSize = size\n                }\n            case .mimeType:\n                mimeType = String(data: Data(value), encoding: .utf8)\n            case .content:\n                let proposedSize = content.count + value.count\n                if proposedSize > FileTransferLimits.maxPayloadBytes {\n                    return nil\n                }\n                content.append(contentsOf: value)\n            case nil:\n                continue\n            }\n        }\n\n        guard !content.isEmpty else { return nil }\n        guard FileTransferLimits.isValidPayload(content.count) else { return nil }\n        return BitchatFilePacket(\n            fileName: fileName,\n            fileSize: fileSize ?? UInt64(content.count),\n            mimeType: mimeType,\n            content: content\n        )\n    }\n}\n"
  },
  {
    "path": "bitchat/Protocols/BitchatProtocol.swift",
    "content": "//\n// BitchatProtocol.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\n///\n/// # BitchatProtocol\n///\n/// Defines the application-layer protocol for BitChat mesh networking, including\n/// message types, packet structures, and encoding/decoding logic.\n///\n/// ## Overview\n/// BitchatProtocol implements a binary protocol optimized for Bluetooth LE's\n/// constrained bandwidth and MTU limitations. It provides:\n/// - Efficient binary message encoding\n/// - Message fragmentation for large payloads\n/// - TTL-based routing for mesh networks\n/// - Privacy features like padding and timing obfuscation\n/// - Integration points for end-to-end encryption\n///\n/// ## Protocol Design\n/// The protocol uses a compact binary format to minimize overhead:\n/// - 1-byte message type identifier\n/// - Variable-length fields with length prefixes\n/// - Network byte order (big-endian) for multi-byte values\n/// - PKCS#7-style padding for privacy\n///\n/// ## Message Flow\n/// 1. **Creation**: Messages are created with type, content, and metadata\n/// 2. **Encoding**: Converted to binary format with proper field ordering\n/// 3. **Fragmentation**: Split if larger than BLE MTU (512 bytes)\n/// 4. **Transmission**: Sent via BLEService\n/// 5. **Routing**: Relayed by intermediate nodes (TTL decrements)\n/// 6. **Reassembly**: Fragments collected and reassembled\n/// 7. **Decoding**: Binary data parsed back to message objects\n///\n/// ## Security Considerations\n/// - Message padding obscures actual content length\n/// - Timing obfuscation prevents traffic analysis\n/// - Integration with Noise Protocol for E2E encryption\n/// - No persistent identifiers in protocol headers\n///\n/// ## Message Types\n/// - **Announce/Leave**: Peer presence notifications\n/// - **Message**: User chat messages (broadcast or directed)\n/// - **Fragment**: Multi-part message handling\n/// - **Delivery/Read**: Message acknowledgments\n/// - **Noise**: Encrypted channel establishment\n/// - **Version**: Protocol version negotiation\n///\n/// ## Future Extensions\n/// The protocol is designed to be extensible:\n/// - Reserved message type ranges for future use\n/// - Version field for protocol evolution\n/// - Optional fields for new features\n///\n\nimport Foundation\nimport CoreBluetooth\n\n// MARK: - Message Types\n\n/// Simplified BitChat protocol message types.\n/// Reduced from 24 types to just 6 essential ones.\n/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.\nenum MessageType: UInt8 {\n    // Public messages (unencrypted)\n    case announce = 0x01        // \"I'm here\" with nickname\n    case message = 0x02         // Public chat message  \n    case leave = 0x03           // \"I'm leaving\"\n    case requestSync = 0x21     // GCS filter-based sync request (local-only)\n    \n    // Noise encryption\n    case noiseHandshake = 0x10  // Handshake (init or response determined by payload)\n    case noiseEncrypted = 0x11  // All encrypted payloads (messages, receipts, etc.)\n    \n    // Fragmentation (simplified)\n    case fragment = 0x20        // Single fragment type for large messages\n    case fileTransfer = 0x22    // Binary file/audio/image payloads\n    \n    var description: String {\n        switch self {\n        case .announce: return \"announce\"\n        case .message: return \"message\"\n        case .leave: return \"leave\"\n        case .requestSync: return \"requestSync\"\n        case .noiseHandshake: return \"noiseHandshake\"\n        case .noiseEncrypted: return \"noiseEncrypted\"\n        case .fragment: return \"fragment\"\n        case .fileTransfer: return \"fileTransfer\"\n        }\n    }\n}\n\n// MARK: - Noise Payload Types\n\n/// Types of payloads embedded within noiseEncrypted messages.\n/// The first byte of decrypted Noise payload indicates the type.\n/// This provides privacy - observers can't distinguish message types.\nenum NoisePayloadType: UInt8 {\n    // Messages and status\n    case privateMessage = 0x01      // Private chat message\n    case readReceipt = 0x02         // Message was read\n    case delivered = 0x03           // Message was delivered\n    // Verification (QR-based OOB binding)\n    case verifyChallenge = 0x10     // Verification challenge\n    case verifyResponse  = 0x11     // Verification response\n    \n    var description: String {\n        switch self {\n        case .privateMessage: return \"privateMessage\"\n        case .readReceipt: return \"readReceipt\"\n        case .delivered: return \"delivered\"\n        case .verifyChallenge: return \"verifyChallenge\"\n        case .verifyResponse: return \"verifyResponse\"\n        }\n    }\n}\n\n// MARK: - Handshake State\n\n// Lazy handshake state tracking\nenum LazyHandshakeState {\n    case none                    // No session, no handshake attempted\n    case handshakeQueued        // User action requires handshake\n    case handshaking           // Currently in handshake process\n    case established           // Session ready for use\n    case failed(Error)         // Handshake failed\n}\n\n// MARK: - Delivery Status\n\n// Delivery status for messages\nenum DeliveryStatus: Codable, Equatable, Hashable {\n    case sending\n    case sent  // Left our device\n    case delivered(to: String, at: Date)  // Confirmed by recipient\n    case read(by: String, at: Date)  // Seen by recipient\n    case failed(reason: String)\n    case partiallyDelivered(reached: Int, total: Int)  // For rooms\n    \n    var displayText: String {\n        switch self {\n        case .sending:\n            return \"Sending...\"\n        case .sent:\n            return \"Sent\"\n        case .delivered(let nickname, _):\n            return \"Delivered to \\(nickname)\"\n        case .read(let nickname, _):\n            return \"Read by \\(nickname)\"\n        case .failed(let reason):\n            return \"Failed: \\(reason)\"\n        case .partiallyDelivered(let reached, let total):\n            return \"Delivered to \\(reached)/\\(total)\"\n        }\n    }\n}\n\n// MARK: - Delegate Protocol\n\nprotocol BitchatDelegate: AnyObject {\n    func didReceiveMessage(_ message: BitchatMessage)\n    func didConnectToPeer(_ peerID: PeerID)\n    func didDisconnectFromPeer(_ peerID: PeerID)\n    func didUpdatePeerList(_ peers: [PeerID])\n\n    // Optional method to check if a fingerprint belongs to a favorite peer\n    func isFavorite(fingerprint: String) -> Bool\n\n    func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)\n\n    // Low-level events for better separation of concerns\n    func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)\n\n    // Bluetooth state updates for user notifications\n    func didUpdateBluetoothState(_ state: CBManagerState)\n    func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)\n}\n\n// Provide default implementation to make it effectively optional\nextension BitchatDelegate {\n    func isFavorite(fingerprint: String) -> Bool {\n        return false\n    }\n    \n    func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {\n        // Default empty implementation\n    }\n\n    func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {\n        // Default empty implementation\n    }\n\n    func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {\n        // Default empty implementation\n    }\n}\n"
  },
  {
    "path": "bitchat/Protocols/Geohash.swift",
    "content": "import Foundation\n\n/// Lightweight Geohash encoder used for Location Channels.\n/// Encodes latitude/longitude to base32 geohash with a fixed precision.\nenum Geohash {\n    private static let base32Chars = Array(\"0123456789bcdefghjkmnpqrstuvwxyz\")\n    private static let base32Map: [Character: Int] = {\n        var map: [Character: Int] = [:]\n        for (i, c) in base32Chars.enumerated() { map[c] = i }\n        return map\n    }()\n\n    /// Validates a geohash string for building-level precision (8 characters).\n    /// - Parameter geohash: The geohash string to validate\n    /// - Returns: true if valid 8-character base32 geohash, false otherwise\n    static func isValidBuildingGeohash(_ geohash: String) -> Bool {\n        guard geohash.count == 8 else { return false }\n        return geohash.lowercased().allSatisfy { base32Map[$0] != nil }\n    }\n\n    /// Encodes the provided coordinates into a geohash string.\n    /// - Parameters:\n    ///   - latitude: Latitude in degrees (-90...90)\n    ///   - longitude: Longitude in degrees (-180...180)\n    ///   - precision: Number of geohash characters (2-12 typical). Values <= 0 return an empty string.\n    /// - Returns: Base32 geohash string of length `precision`.\n    static func encode(latitude: Double, longitude: Double, precision: Int) -> String {\n        guard precision > 0 else { return \"\" }\n\n        var latInterval: (Double, Double) = (-90.0, 90.0)\n        var lonInterval: (Double, Double) = (-180.0, 180.0)\n\n        var isEven = true\n        var bit = 0\n        var ch = 0\n        var geohash: [Character] = []\n\n        let lat = max(-90.0, min(90.0, latitude))\n        let lon = max(-180.0, min(180.0, longitude))\n\n        while geohash.count < precision {\n            if isEven {\n                let mid = (lonInterval.0 + lonInterval.1) / 2\n                if lon >= mid {\n                    ch |= (1 << (4 - bit))\n                    lonInterval.0 = mid\n                } else {\n                    lonInterval.1 = mid\n                }\n            } else {\n                let mid = (latInterval.0 + latInterval.1) / 2\n                if lat >= mid {\n                    ch |= (1 << (4 - bit))\n                    latInterval.0 = mid\n                } else {\n                    latInterval.1 = mid\n                }\n            }\n\n            isEven.toggle()\n            if bit < 4 {\n                bit += 1\n            } else {\n                geohash.append(base32Chars[ch])\n                bit = 0\n                ch = 0\n            }\n        }\n\n        return String(geohash)\n    }\n\n    /// Decodes a geohash into the center latitude/longitude of its bounding box.\n    /// - Parameter geohash: Base32 geohash string.\n    /// - Returns: (lat, lon) center coordinate.\n    static func decodeCenter(_ geohash: String) -> (lat: Double, lon: Double) {\n        var latInterval: (Double, Double) = (-90.0, 90.0)\n        var lonInterval: (Double, Double) = (-180.0, 180.0)\n\n        var isEven = true\n        for ch in geohash.lowercased() {\n            guard let cd = base32Map[ch] else { continue }\n            for mask in [16, 8, 4, 2, 1] {\n                if isEven {\n                    let mid = (lonInterval.0 + lonInterval.1) / 2\n                    if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }\n                } else {\n                    let mid = (latInterval.0 + latInterval.1) / 2\n                    if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }\n                }\n                isEven.toggle()\n            }\n        }\n        let lat = (latInterval.0 + latInterval.1) / 2\n        let lon = (lonInterval.0 + lonInterval.1) / 2\n        return (lat, lon)\n    }\n\n    /// Decodes a geohash into its latitude and longitude bounds.\n    /// - Parameter geohash: Base32 geohash string.\n    /// - Returns: (latMin, latMax, lonMin, lonMax)\n    static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) {\n        var latInterval: (Double, Double) = (-90.0, 90.0)\n        var lonInterval: (Double, Double) = (-180.0, 180.0)\n\n        var isEven = true\n        for ch in geohash.lowercased() {\n            guard let cd = base32Map[ch] else { continue }\n            for mask in [16, 8, 4, 2, 1] {\n                if isEven {\n                    let mid = (lonInterval.0 + lonInterval.1) / 2\n                    if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }\n                } else {\n                    let mid = (latInterval.0 + latInterval.1) / 2\n                    if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }\n                }\n                isEven.toggle()\n            }\n        }\n        return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)\n    }\n\n    /// Returns all 8 neighboring geohash cells at the same precision.\n    /// - Parameter geohash: Base32 geohash string.\n    /// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).\n    static func neighbors(of geohash: String) -> [String] {\n        guard !geohash.isEmpty else { return [] }\n\n        let precision = geohash.count\n        let bounds = decodeBounds(geohash)\n        let center = decodeCenter(geohash)\n\n        // Calculate cell dimensions\n        let latHeight = bounds.latMax - bounds.latMin\n        let lonWidth = bounds.lonMax - bounds.lonMin\n\n        // Helper to wrap longitude around ±180\n        func wrapLongitude(_ lon: Double) -> Double {\n            var wrapped = lon\n            while wrapped > 180.0 { wrapped -= 360.0 }\n            while wrapped < -180.0 { wrapped += 360.0 }\n            return wrapped\n        }\n\n        // Helper to clamp latitude to ±90\n        func clampLatitude(_ lat: Double) -> Double {\n            return max(-90.0, min(90.0, lat))\n        }\n\n        // Calculate 8 neighbor centers\n        let neighbors: [(lat: Double, lon: Double)] = [\n            (center.lat + latHeight, center.lon),                    // N\n            (center.lat + latHeight, center.lon + lonWidth),         // NE\n            (center.lat, center.lon + lonWidth),                     // E\n            (center.lat - latHeight, center.lon + lonWidth),         // SE\n            (center.lat - latHeight, center.lon),                    // S\n            (center.lat - latHeight, center.lon - lonWidth),         // SW\n            (center.lat, center.lon - lonWidth),                     // W\n            (center.lat + latHeight, center.lon - lonWidth)          // NW\n        ]\n\n        // Encode each neighbor, handling boundary conditions\n        return neighbors.compactMap { neighbor in\n            let lat = clampLatitude(neighbor.lat)\n            let lon = wrapLongitude(neighbor.lon)\n\n            // Skip if we've crossed a pole (latitude clamped to boundary)\n            if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {\n                return nil\n            }\n\n            return encode(latitude: lat, longitude: lon, precision: precision)\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Protocols/LocationChannel.swift",
    "content": "import Foundation\n\n/// Levels of location channels mapped to geohash precisions.\nenum GeohashChannelLevel: CaseIterable, Codable, Equatable {\n    case building\n    case block\n    case neighborhood\n    case city\n    case province   // previously .region\n    case region     // previously .country\n\n    /// Geohash length used for this level.\n    var precision: Int {\n        switch self {\n        case .building: return 8\n        case .block: return 7\n        case .neighborhood: return 6\n        case .city: return 5\n        case .province: return 4\n        case .region: return 2\n    }\n    }\n\n    var displayName: String {\n        switch self {\n        case .building:\n            return String(localized: \"location_levels.building\", comment: \"Name for building-level location channel\")\n        case .block:\n            return String(localized: \"location_levels.block\", comment: \"Name for block-level location channel\")\n        case .neighborhood:\n            return String(localized: \"location_levels.neighborhood\", comment: \"Name for neighborhood-level location channel\")\n        case .city:\n            return String(localized: \"location_levels.city\", comment: \"Name for city-level location channel\")\n        case .province:\n            return String(localized: \"location_levels.province\", comment: \"Name for province-level location channel\")\n        case .region:\n            return String(localized: \"location_levels.region\", comment: \"Name for region-level location channel\")\n        }\n    }\n}\n// Backward-compatible Codable for renamed cases\nextension GeohashChannelLevel {\n    init(from decoder: Decoder) throws {\n        let container = try decoder.singleValueContainer()\n        if let raw = try? container.decode(String.self) {\n            switch raw {\n            case \"building\": self = .building\n            case \"block\": self = .block\n            case \"neighborhood\": self = .neighborhood\n            case \"city\": self = .city\n            case \"region\": self = .province      // old \"region\" maps to new .province\n            case \"country\": self = .region       // old \"country\" maps to new .region\n            case \"province\": self = .province\n            default:\n                self = .block\n            }\n        } else if let precision = try? container.decode(Int.self) {\n            switch precision {\n            case 8: self = .building\n            case 7: self = .block\n            case 6: self = .neighborhood\n            case 5: self = .city\n            case 4: self = .province\n            case 0...3: self = .region\n            default: self = .block\n            }\n        } else {\n            self = .block\n        }\n    }\n\n    func encode(to encoder: Encoder) throws {\n        var container = encoder.singleValueContainer()\n        switch self {\n        case .building: try container.encode(\"building\")\n        case .block: try container.encode(\"block\")\n        case .neighborhood: try container.encode(\"neighborhood\")\n        case .city: try container.encode(\"city\")\n        case .province: try container.encode(\"province\")\n        case .region: try container.encode(\"region\")\n        }\n    }\n}\n\n/// A computed geohash channel option.\nstruct GeohashChannel: Codable, Equatable, Hashable, Identifiable {\n    let level: GeohashChannelLevel\n    let geohash: String\n\n    var id: String { \"\\(level)-\\(geohash)\" }\n\n    var displayName: String {\n        \"\\(level.displayName) • \\(geohash)\"\n    }\n}\n\n/// Identifier for current public chat channel (mesh or a location geohash).\nenum ChannelID: Equatable, Codable {\n    case mesh\n    case location(GeohashChannel)\n\n    /// Human readable name for UI.\n    var displayName: String {\n        switch self {\n        case .mesh:\n            return \"Mesh\"\n        case .location(let ch):\n            return ch.displayName\n        }\n    }\n\n    /// Nostr tag value for scoping (geohash), if applicable.\n    var nostrGeohashTag: String? {\n        switch self {\n        case .mesh: return nil\n        case .location(let ch): return ch.geohash\n        }\n    }\n    \n    var isMesh: Bool {\n        switch self {\n        case .mesh:     true\n        case .location: false\n        }\n    }\n    \n    var isLocation: Bool {\n        switch self {\n        case .mesh:     false\n        case .location: true\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Protocols/Packets.swift",
    "content": "import Foundation\n\n// MARK: - Protocol TLV Packets\n\nstruct AnnouncementPacket {\n    let nickname: String\n    let noisePublicKey: Data            // Noise static public key (Curve25519.KeyAgreement)\n    let signingPublicKey: Data          // Ed25519 public key for signing\n    let directNeighbors: [Data]?        // 8-byte peer IDs\n\n    private enum TLVType: UInt8 {\n        case nickname = 0x01\n        case noisePublicKey = 0x02\n        case signingPublicKey = 0x03\n        case directNeighbors = 0x04\n    }\n\n    func encode() -> Data? {\n        var data = Data()\n        // Reserve: TLVs for nickname (2 + n), noise key (2 + 32), signing key (2 + 32)\n        data.reserveCapacity(2 + min(nickname.count, 255) + 2 + noisePublicKey.count + 2 + signingPublicKey.count)\n\n        // TLV for nickname\n        guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }\n        data.append(TLVType.nickname.rawValue)\n        data.append(UInt8(nicknameData.count))\n        data.append(nicknameData)\n\n        // TLV for noise public key\n        guard noisePublicKey.count <= 255 else { return nil }\n        data.append(TLVType.noisePublicKey.rawValue)\n        data.append(UInt8(noisePublicKey.count))\n        data.append(noisePublicKey)\n\n        // TLV for signing public key\n        guard signingPublicKey.count <= 255 else { return nil }\n        data.append(TLVType.signingPublicKey.rawValue)\n        data.append(UInt8(signingPublicKey.count))\n        data.append(signingPublicKey)\n        \n        // TLV for direct neighbors (optional)\n        if let neighbors = directNeighbors, !neighbors.isEmpty {\n            let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }\n            if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {\n                data.append(TLVType.directNeighbors.rawValue)\n                data.append(UInt8(neighborsData.count))\n                data.append(neighborsData)\n            }\n        }\n\n        return data\n    }\n\n    static func decode(from data: Data) -> AnnouncementPacket? {\n        var offset = 0\n        var nickname: String?\n        var noisePublicKey: Data?\n        var signingPublicKey: Data?\n        var directNeighbors: [Data]?\n\n        while offset + 2 <= data.count {\n            let typeRaw = data[offset]\n            offset += 1\n            let length = Int(data[offset])\n            offset += 1\n\n            guard offset + length <= data.count else { return nil }\n            let value = data[offset..<offset + length]\n            offset += length\n\n            if let type = TLVType(rawValue: typeRaw) {\n                switch type {\n                case .nickname:\n                    nickname = String(data: value, encoding: .utf8)\n                case .noisePublicKey:\n                    noisePublicKey = Data(value)\n                case .signingPublicKey:\n                    signingPublicKey = Data(value)\n                case .directNeighbors:\n                    if length > 0 && length % 8 == 0 {\n                        var neighbors = [Data]()\n                        let count = length / 8\n                        for i in 0..<count {\n                            let start = value.startIndex + i * 8\n                            let end = start + 8\n                            neighbors.append(Data(value[start..<end]))\n                        }\n                        directNeighbors = neighbors\n                    }\n                }\n            } else {\n                // Unknown TLV; skip (tolerant decoder for forward compatibility)\n                continue\n            }\n        }\n\n        guard let nickname = nickname, let noisePublicKey = noisePublicKey, let signingPublicKey = signingPublicKey else { return nil }\n        return AnnouncementPacket(\n            nickname: nickname,\n            noisePublicKey: noisePublicKey,\n            signingPublicKey: signingPublicKey,\n            directNeighbors: directNeighbors\n        )\n    }\n}\n\nstruct PrivateMessagePacket {\n    let messageID: String\n    let content: String\n\n    private enum TLVType: UInt8 {\n        case messageID = 0x00\n        case content = 0x01\n    }\n\n    func encode() -> Data? {\n        var data = Data()\n        data.reserveCapacity(2 + min(messageID.count, 255) + 2 + min(content.count, 255))\n\n        // TLV for messageID\n        guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }\n        data.append(TLVType.messageID.rawValue)\n        data.append(UInt8(messageIDData.count))\n        data.append(messageIDData)\n\n        // TLV for content\n        guard let contentData = content.data(using: .utf8), contentData.count <= 255 else { return nil }\n        data.append(TLVType.content.rawValue)\n        data.append(UInt8(contentData.count))\n        data.append(contentData)\n\n        return data\n    }\n\n    static func decode(from data: Data) -> PrivateMessagePacket? {\n        var offset = 0\n        var messageID: String?\n        var content: String?\n\n        while offset + 2 <= data.count {\n            guard let type = TLVType(rawValue: data[offset]) else { return nil }\n            offset += 1\n\n            let length = Int(data[offset])\n            offset += 1\n\n            guard offset + length <= data.count else { return nil }\n            let value = data[offset..<offset + length]\n            offset += length\n\n            switch type {\n            case .messageID:\n                messageID = String(data: value, encoding: .utf8)\n            case .content:\n                content = String(data: value, encoding: .utf8)\n            }\n        }\n\n        guard let messageID = messageID, let content = content else { return nil }\n        return PrivateMessagePacket(messageID: messageID, content: content)\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/AutocompleteService.swift",
    "content": "//\n// AutocompleteService.swift\n// bitchat\n//\n// Handles autocomplete suggestions for mentions and commands\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Foundation\n\n/// Manages autocomplete functionality for chat\nfinal class AutocompleteService {\n    private let mentionRegex = try? NSRegularExpression(pattern: \"@([\\\\p{L}0-9_]*)$\", options: [])\n    private let commandRegex = try? NSRegularExpression(pattern: \"^/([a-z]*)$\", options: [])\n    \n    private let commands = [\n        \"/msg\", \"/who\", \"/clear\",\n        \"/hug\", \"/slap\", \"/fav\", \"/unfav\",\n        \"/block\", \"/unblock\"\n    ]\n    \n    /// Get autocomplete suggestions for current text\n    func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {\n        let textToPosition = String(text.prefix(cursorPosition))\n        \n        // Check for mention autocomplete\n        if let (mentionSuggestions, mentionRange) = getMentionSuggestions(textToPosition, peers: peers) {\n            return (mentionSuggestions, mentionRange)\n        }\n        \n        // Don't handle command autocomplete here - ContentView handles it with better UI\n        // if let (commandSuggestions, commandRange) = getCommandSuggestions(textToPosition) {\n        //     return (commandSuggestions, commandRange)\n        // }\n        \n        return ([], nil)\n    }\n    \n    /// Apply selected suggestion to text\n    func applySuggestion(_ suggestion: String, to text: String, range: NSRange) -> String {\n        guard let textRange = Range(range, in: text) else { return text }\n        \n        var replacement = suggestion\n        \n        // Add space after command if it takes arguments\n        if suggestion.hasPrefix(\"/\") && needsArgument(command: suggestion) {\n            replacement += \" \"\n        }\n        \n        return text.replacingCharacters(in: textRange, with: replacement)\n    }\n    \n    // MARK: - Private Methods\n    \n    private func getMentionSuggestions(_ text: String, peers: [String]) -> ([String], NSRange)? {\n        guard let regex = mentionRegex else { return nil }\n        \n        let nsText = text as NSString\n        let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))\n        \n        guard let match = matches.last else { return nil }\n        \n        let fullRange = match.range(at: 0)\n        let captureRange = match.range(at: 1)\n        let prefix = nsText.substring(with: captureRange).lowercased()\n        \n        let suggestions = peers\n            .filter { $0.lowercased().hasPrefix(prefix) }\n            .sorted()\n            .prefix(5)\n            .map { \"@\\($0)\" }\n        \n        return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)\n    }\n    \n    private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {\n        guard let regex = commandRegex else { return nil }\n        \n        let nsText = text as NSString\n        let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))\n        \n        guard let match = matches.last else { return nil }\n        \n        let fullRange = match.range(at: 0)\n        let captureRange = match.range(at: 1)\n        let prefix = nsText.substring(with: captureRange).lowercased()\n        \n        let suggestions = commands\n            .filter { $0.hasPrefix(\"/\\(prefix)\") }\n            .sorted()\n            .prefix(5)\n        \n        return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)\n    }\n    \n    private func needsArgument(command: String) -> Bool {\n        switch command {\n        case \"/who\", \"/clear\":\n            return false\n        default:\n            return true\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/BLE/BLEService.swift",
    "content": "import BitLogger\nimport Foundation\nimport CoreBluetooth\nimport Combine\nimport CryptoKit\n#if os(iOS)\nimport UIKit\n#endif\n\n/// BLEService — Bluetooth Mesh Transport\n/// - Emits events exclusively via `BitchatDelegate` for UI.\n/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).\n/// - A lightweight `peerSnapshotPublisher` is provided for non-UI services.\nfinal class BLEService: NSObject {\n    \n    // MARK: - Constants\n    \n    #if DEBUG\n    static let serviceUUID = CBUUID(string: \"F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A\") // testnet\n    #else\n    static let serviceUUID = CBUUID(string: \"F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C\") // mainnet\n    #endif\n    static let characteristicUUID = CBUUID(string: \"A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D\")\n    private static let centralRestorationID = \"chat.bitchat.ble.central\"\n    private static let peripheralRestorationID = \"chat.bitchat.ble.peripheral\"\n    \n    // Default per-fragment chunk size when link limits are unknown\n    private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize\n    private let bleMaxMTU = 512\n    private let maxMessageLength = InputValidator.Limits.maxMessageLength\n    private let messageTTL: UInt8 = TransportConfig.messageTTLDefault\n    // Flood/battery controls\n    private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies\n    private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays\n    \n    // MARK: - Core State (5 Essential Collections)\n    \n    // 1. Consolidated Peripheral Tracking\n    private struct PeripheralState {\n        let peripheral: CBPeripheral\n        var characteristic: CBCharacteristic?\n        var peerID: PeerID?\n        var isConnecting: Bool = false\n        var isConnected: Bool = false\n        var lastConnectionAttempt: Date? = nil\n        var assembler = NotificationStreamAssembler()\n    }\n    private var peripherals: [String: PeripheralState] = [:]  // UUID -> PeripheralState\n    private var peerToPeripheralUUID: [PeerID: String] = [:]  // PeerID -> Peripheral UUID\n    \n    // 2. BLE Centrals (when acting as peripheral)\n    private var subscribedCentrals: [CBCentral] = []\n    private var centralToPeerID: [String: PeerID] = [:]  // Central UUID -> Peer ID mapping\n\n    // BCH-01-004: Rate-limiting for subscription-triggered announces\n    // Tracks subscription attempts per central to prevent enumeration attacks\n    private struct SubscriptionRateLimitState {\n        var lastAnnounceTime: Date\n        var attemptCount: Int\n        var currentBackoffSeconds: TimeInterval\n    }\n    private var centralSubscriptionRateLimits: [String: SubscriptionRateLimitState] = [:]  // Central UUID -> rate limit state\n    \n    // 3. Peer Information (single source of truth)\n    private struct PeerInfo {\n        let peerID: PeerID\n        var nickname: String\n        var isConnected: Bool\n        var noisePublicKey: Data?\n        var signingPublicKey: Data?\n        var isVerifiedNickname: Bool\n        var lastSeen: Date\n    }\n    private var peers: [PeerID: PeerInfo] = [:]\n    private var currentPeerIDs: [PeerID] {\n        Array(peers.keys)\n    }\n    \n    // 4. Efficient Message Deduplication\n    private let messageDeduplicator = MessageDeduplicator()\n    private var selfBroadcastMessageIDs: [String: (id: String, timestamp: Date)] = [:]\n    private lazy var mediaDateFormatter: DateFormatter = {\n        let formatter = DateFormatter()\n        formatter.dateFormat = \"yyyyMMdd_HHmmss\"\n        return formatter\n    }()\n    private let meshTopology = MeshTopologyTracker()\n    \n    // 5. Fragment Reassembly (necessary for messages > MTU)\n    private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 }\n    private var incomingFragments: [FragmentKey: [Int: Data]] = [:]\n    private var fragmentMetadata: [FragmentKey: (type: UInt8, total: Int, timestamp: Date)] = [:]\n    private struct ActiveTransferState {\n        let totalFragments: Int\n        var sentFragments: Int\n        var workItems: [DispatchWorkItem]\n    }\n    private var activeTransfers: [String: ActiveTransferState] = [:]\n    // Backoff for peripherals that recently timed out connecting\n    private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout\n    \n    // Simple announce throttling\n    private var lastAnnounceSent = Date.distantPast\n    private let announceMinInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval\n    \n    // Application state tracking (thread-safe)\n    #if os(iOS)\n    private var isAppActive: Bool = true  // Assume active initially\n    #endif\n    \n    // MARK: - Core BLE Objects\n    \n    private var centralManager: CBCentralManager?\n    private var peripheralManager: CBPeripheralManager?\n    private var characteristic: CBMutableCharacteristic?\n    \n    // MARK: - Identity\n    \n    private var noiseService: NoiseEncryptionService\n    private let identityManager: SecureIdentityStateManagerProtocol\n    private let keychain: KeychainManagerProtocol\n    private let idBridge: NostrIdentityBridge\n    private var myPeerIDData: Data = Data()\n\n    // MARK: - Advertising Privacy\n    // No Local Name by default for maximum privacy. No rotating alias.\n    \n    // MARK: - Queues\n    \n    private let messageQueue = DispatchQueue(label: \"mesh.message\", attributes: .concurrent)\n    private let collectionsQueue = DispatchQueue(label: \"mesh.collections\", attributes: .concurrent)\n    private let messageQueueKey = DispatchSpecificKey<Void>()\n    private let bleQueue = DispatchQueue(label: \"mesh.bluetooth\", qos: .userInitiated)\n    private let bleQueueKey = DispatchSpecificKey<Void>()\n    \n    // Queue for messages pending handshake completion\n    private var pendingMessagesAfterHandshake: [PeerID: [(content: String, messageID: String)]] = [:]\n    // Noise typed payloads (ACKs, read receipts, etc.) pending handshake\n    private var pendingNoisePayloadsAfterHandshake: [PeerID: [Data]] = [:]\n    // Queue for notifications that failed due to full queue\n    private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = []\n\n    // Accumulate long write chunks per central until a full frame decodes\n    private var pendingWriteBuffers: [String: Data] = [:]\n    // Relay jitter scheduling to reduce redundant floods\n    private var scheduledRelays: [String: DispatchWorkItem] = [:]\n    // Track short-lived traffic bursts to adapt announces/scanning under load\n    private var recentPacketTimestamps: [Date] = []\n\n    // Ingress link tracking for last-hop suppression\n    private enum LinkID: Hashable {\n        case peripheral(String)\n        case central(String)\n    }\n    private var ingressByMessageID: [String: (link: LinkID, timestamp: Date)] = [:]\n\n    // Backpressure-aware write queue per peripheral\n    private struct OutboundPriority: Comparable {\n        let level: Int\n        let suborder: Int\n\n        static let high = OutboundPriority(level: 0, suborder: 0)\n        static func fragment(totalFragments: Int) -> OutboundPriority {\n            OutboundPriority(level: 1, suborder: max(1, min(totalFragments, Int(UInt16.max))))\n        }\n        static let fileTransfer = OutboundPriority(level: 2, suborder: Int.max - 1)\n        static let low = OutboundPriority(level: 2, suborder: Int.max)\n\n        static func < (lhs: OutboundPriority, rhs: OutboundPriority) -> Bool {\n            if lhs.level != rhs.level { return lhs.level < rhs.level }\n            return lhs.suborder < rhs.suborder\n        }\n    }\n    private struct PendingWrite {\n        let priority: OutboundPriority\n        let data: Data\n    }\n    private struct PendingFragmentTransfer {\n        let packet: BitchatPacket\n        let pad: Bool\n        let maxChunk: Int?\n        let directedPeer: PeerID?\n        let transferId: String?\n    }\n    private var pendingPeripheralWrites: [String: [PendingWrite]] = [:]\n    private var pendingFragmentTransfers: [PendingFragmentTransfer] = []\n    // Debounce duplicate disconnect notifies\n    private var recentDisconnectNotifies: [PeerID: Date] = [:]\n    // Store-and-forward for directed messages when we have no links\n    // Keyed by recipient short peerID -> messageID -> (packet, enqueuedAt)\n    private var pendingDirectedRelays: [PeerID: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]\n    // Debounce for 'reconnected' logs\n    private var lastReconnectLogAt: [PeerID: Date] = [:]\n\n    // MARK: - Gossip Sync\n    private var gossipSyncManager: GossipSyncManager?\n    private let requestSyncManager = RequestSyncManager()\n    \n    // MARK: - Maintenance Timer\n    \n    private var maintenanceTimer: DispatchSourceTimer?  // Single timer for all maintenance tasks\n    private var maintenanceCounter = 0  // Track maintenance cycles\n\n    // MARK: - Connection budget & scheduling (central role)\n    private let maxCentralLinks = TransportConfig.bleMaxCentralLinks\n    private let connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval\n    private var lastGlobalConnectAttempt: Date = .distantPast\n    private struct ConnectionCandidate {\n        let peripheral: CBPeripheral\n        let rssi: Int\n        let name: String\n        let isConnectable: Bool\n        let discoveredAt: Date\n    }\n    private var connectionCandidates: [ConnectionCandidate] = []\n    private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures\n    private var lastIsolatedAt: Date? = nil\n    private var dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault\n\n    // MARK: - Adaptive scanning duty-cycle\n    private var scanDutyTimer: DispatchSourceTimer?\n    private var dutyEnabled: Bool = true\n    private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration\n    private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration\n    private var dutyActive: Bool = false\n    \n    // Debounced publish to coalesce rapid changes\n    private var lastPeerPublishAt: Date = .distantPast\n    private var peerPublishPending: Bool = false\n    private let peerPublishMinInterval: TimeInterval = 0.1\n    private func requestPeerDataPublish() {\n        let now = Date()\n        let elapsed = now.timeIntervalSince(lastPeerPublishAt)\n        if elapsed >= peerPublishMinInterval {\n            lastPeerPublishAt = now\n            publishFullPeerData()\n        } else if !peerPublishPending {\n            peerPublishPending = true\n            let delay = peerPublishMinInterval - elapsed\n            messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in\n                guard let self = self else { return }\n                self.lastPeerPublishAt = Date()\n                self.peerPublishPending = false\n                self.publishFullPeerData()\n            }\n        }\n    }\n    \n    // MARK: - Initialization\n    \n    init(\n        keychain: KeychainManagerProtocol,\n        idBridge: NostrIdentityBridge,\n        identityManager: SecureIdentityStateManagerProtocol,\n        initializeBluetoothManagers: Bool = true\n    ) {\n        self.keychain = keychain\n        self.idBridge = idBridge\n        noiseService = NoiseEncryptionService(keychain: keychain)\n        self.identityManager = identityManager\n        super.init()\n        \n        configureNoiseServiceCallbacks(for: noiseService)\n        refreshPeerIdentity()\n        \n        // Set queue key for identification\n        messageQueue.setSpecific(key: messageQueueKey, value: ())\n        \n        // Set up application state tracking (iOS only)\n        #if os(iOS)\n        // Check initial state on main thread\n        if Thread.isMainThread {\n            isAppActive = UIApplication.shared.applicationState == .active\n        } else {\n            DispatchQueue.main.sync {\n                isAppActive = UIApplication.shared.applicationState == .active\n            }\n        }\n        \n        // Observe application state changes\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(appDidBecomeActive),\n            name: UIApplication.didBecomeActiveNotification,\n            object: nil\n        )\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(appDidEnterBackground),\n            name: UIApplication.didEnterBackgroundNotification,\n            object: nil\n        )\n        #endif\n        \n        // Tag BLE queue for re-entrancy detection\n        bleQueue.setSpecific(key: bleQueueKey, value: ())\n\n        if initializeBluetoothManagers {\n            // Initialize BLE on background queue to prevent main thread blocking.\n            #if os(iOS)\n            let centralOptions: [String: Any] = [\n                CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID\n            ]\n            centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)\n\n            let peripheralOptions: [String: Any] = [\n                CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID\n            ]\n            peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)\n            #else\n            centralManager = CBCentralManager(delegate: self, queue: bleQueue)\n            peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)\n            #endif\n        }\n        \n        // Single maintenance timer for all periodic tasks (dispatch-based for determinism)\n        let timer = DispatchSource.makeTimerSource(queue: bleQueue)\n        timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,\n                       repeating: TransportConfig.bleMaintenanceInterval,\n                       leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))\n        timer.setEventHandler { [weak self] in\n            self?.performMaintenance()\n        }\n        timer.resume()\n        maintenanceTimer = timer\n\n        // Publish initial empty state\n        requestPeerDataPublish()\n\n        // Initialize gossip sync manager\n        restartGossipManager()\n    }\n    \n    private func restartGossipManager() {\n        // Stop existing\n        gossipSyncManager?.stop()\n        \n        let config = GossipSyncManager.Config(\n            seenCapacity: TransportConfig.syncSeenCapacity,\n            gcsMaxBytes: TransportConfig.syncGCSMaxBytes,\n            gcsTargetFpr: TransportConfig.syncGCSTargetFpr,\n            maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,\n            maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,\n            stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,\n            stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,\n            fragmentCapacity: TransportConfig.syncFragmentCapacity,\n            fileTransferCapacity: TransportConfig.syncFileTransferCapacity,\n            fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds,\n            fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,\n            messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds\n        )\n        \n        let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)\n        manager.delegate = self\n        manager.start()\n        gossipSyncManager = manager\n    }\n\n    // No advertising policy to set; we never include Local Name in adverts.\n    \n    deinit {\n        maintenanceTimer?.cancel()\n        scanDutyTimer?.cancel()\n        scanDutyTimer = nil\n        centralManager?.stopScan()\n        peripheralManager?.stopAdvertising()\n        #if os(iOS)\n        NotificationCenter.default.removeObserver(self)\n        #endif\n    }\n\n    func resetIdentityForPanic(currentNickname: String) {\n        messageQueue.sync(flags: .barrier) {\n            pendingMessagesAfterHandshake.removeAll()\n            pendingNoisePayloadsAfterHandshake.removeAll()\n        }\n\n        collectionsQueue.sync(flags: .barrier) {\n            pendingPeripheralWrites.removeAll()\n            pendingFragmentTransfers.removeAll()\n            pendingNotifications.removeAll()\n            pendingDirectedRelays.removeAll()\n            ingressByMessageID.removeAll()\n            recentPacketTimestamps.removeAll()\n            scheduledRelays.values.forEach { $0.cancel() }\n            scheduledRelays.removeAll()\n        }\n\n        bleQueue.sync {\n            pendingWriteBuffers.removeAll()\n            recentConnectTimeouts.removeAll()\n        }\n        recentDisconnectNotifies.removeAll()\n\n        noiseService.clearEphemeralStateForPanic()\n        noiseService.clearPersistentIdentity()\n\n        let newNoise = NoiseEncryptionService(keychain: keychain)\n        noiseService = newNoise\n        configureNoiseServiceCallbacks(for: newNoise)\n        refreshPeerIdentity()\n        restartGossipManager()\n\n        setNickname(currentNickname)\n\n        messageDeduplicator.reset()\n        messageQueue.async(flags: .barrier) { [weak self] in\n            self?.selfBroadcastMessageIDs.removeAll()\n        }\n        requestPeerDataPublish()\n        startServices()\n    }\n    \n    // Ensure this runs on message queue to avoid main thread blocking\n    func sendMessage(_ content: String, mentions: [String] = [], to recipientID: PeerID? = nil, messageID: String? = nil, timestamp: Date? = nil) {\n        // Call directly if already on messageQueue, otherwise dispatch\n        if DispatchQueue.getSpecific(key: messageQueueKey) == nil {\n            messageQueue.async { [weak self] in\n                self?.sendMessage(content, mentions: mentions, to: recipientID, messageID: messageID, timestamp: timestamp)\n            }\n            return\n        }\n        \n        guard content.count <= maxMessageLength else {\n            SecureLogger.error(\"Message too long: \\(content.count) chars\", category: .session)\n            return\n        }\n        \n        if let recipientID {\n            sendPrivateMessage(content, to: recipientID, messageID: messageID ?? UUID().uuidString)\n            return\n        }\n        \n        // Public broadcast\n        // Create packet with explicit fields so we can sign it\n        let sendDate = timestamp ?? Date()\n        let sendTimestampMs = UInt64(sendDate.timeIntervalSince1970 * 1000)\n        let basePacket = BitchatPacket(\n            type: MessageType.message.rawValue,\n            senderID: Data(hexString: myPeerID.id) ?? Data(),\n            recipientID: nil,\n            timestamp: sendTimestampMs,\n            payload: Data(content.utf8),\n            signature: nil,\n            ttl: messageTTL\n        )\n        guard let signedPacket = noiseService.signPacket(basePacket) else {\n            SecureLogger.error(\"❌ Failed to sign public message\", category: .security)\n            return\n        }\n        // Pre-mark our own broadcast as processed to avoid handling relayed self copy\n        let senderHex = signedPacket.senderID.hexEncodedString()\n        let dedupID = \"\\(senderHex)-\\(signedPacket.timestamp)-\\(signedPacket.type)\"\n        messageDeduplicator.markProcessed(dedupID)\n        if let messageID {\n            selfBroadcastMessageIDs[dedupID] = (id: messageID, timestamp: sendDate)\n        }\n        // Call synchronously since we're already on background queue\n        broadcastPacket(signedPacket)\n        // Track our own broadcast for sync\n        gossipSyncManager?.onPublicPacketSeen(signedPacket)\n    }\n    \n    // MARK: - Transport Protocol Conformance\n\n    // MARK: Delegates\n    \n    weak var delegate: BitchatDelegate?\n    weak var peerEventsDelegate: TransportPeerEventsDelegate?\n    \n    // MARK: Peer snapshots publisher (non-UI convenience)\n    \n    private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()\n    var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {\n        peerSnapshotSubject.eraseToAnyPublisher()\n    }\n\n    func currentPeerSnapshots() -> [TransportPeerSnapshot] {\n        collectionsQueue.sync {\n            let snapshot = Array(peers.values)\n            let resolvedNames = PeerDisplayNameResolver.resolve(\n                snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) },\n                selfNickname: myNickname\n            )\n            return snapshot.map { info in\n                TransportPeerSnapshot(\n                    peerID: info.peerID,\n                    nickname: resolvedNames[info.peerID] ?? info.nickname,\n                    isConnected: info.isConnected,\n                    noisePublicKey: info.noisePublicKey,\n                    lastSeen: info.lastSeen\n                )\n            }\n        }\n    }\n    \n    // MARK: Identity\n    \n    var myPeerID = PeerID(str: \"\")\n    var myNickname: String = \"anon\"\n    \n    func setNickname(_ nickname: String) {\n        self.myNickname = nickname\n        // Send announce to notify peers of nickname change (force send)\n        sendAnnounce(forceSend: true)\n    }\n    \n    // MARK: Lifecycle\n    \n    func startServices() {\n        // Start BLE services if not already running\n        if centralManager?.state == .poweredOn {\n            centralManager?.scanForPeripherals(\n                withServices: [BLEService.serviceUUID],\n                options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]\n            )\n        }\n        \n        // Send initial announce after services are ready\n        // Use longer delay to avoid conflicts with other announces\n        messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in\n            self?.sendAnnounce(forceSend: true)\n        }\n    }\n    \n    func stopServices() {\n        // Send leave message synchronously to ensure delivery\n        let leavePacket = BitchatPacket(\n            type: MessageType.leave.rawValue,\n            senderID: myPeerIDData,\n            recipientID: nil,\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: Data(),\n            signature: nil,\n            ttl: messageTTL\n        )\n\n        // Send immediately to all connected peers (synchronized access to BLE state)\n        if let data = leavePacket.toBinaryData(padding: false) {\n            let leavePriority = priority(for: leavePacket, data: data)\n\n            // Snapshot BLE state under bleQueue to avoid races with delegate callbacks\n            let (peripheralStates, centralsCount, char) = bleQueue.sync {\n                (Array(peripherals.values), subscribedCentrals.count, characteristic)\n            }\n\n            // Send to peripherals we're connected to as central\n            for state in peripheralStates where state.isConnected {\n                if let characteristic = state.characteristic {\n                    writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: leavePriority)\n                }\n            }\n\n            // Send to centrals subscribed to us as peripheral\n            if centralsCount > 0, let ch = char {\n                peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)\n            }\n        }\n\n        // Give leave message a moment to send (cooperative delay allows BLE callbacks to fire)\n        let deadline = Date().addingTimeInterval(TransportConfig.bleThreadSleepWriteShortDelaySeconds)\n        while Date() < deadline {\n            RunLoop.current.run(until: Date().addingTimeInterval(0.01))\n        }\n\n        // Clear pending notifications\n        collectionsQueue.sync(flags: .barrier) {\n            pendingNotifications.removeAll()\n        }\n\n        // Stop timer\n        maintenanceTimer?.cancel()\n        maintenanceTimer = nil\n        scanDutyTimer?.cancel()\n        scanDutyTimer = nil\n\n        centralManager?.stopScan()\n        peripheralManager?.stopAdvertising()\n\n        // Disconnect all peripherals (synchronized access)\n        let peripheralsToDisconnect = bleQueue.sync { Array(peripherals.values) }\n        for state in peripheralsToDisconnect {\n            centralManager?.cancelPeripheralConnection(state.peripheral)\n        }\n    }\n    \n    func emergencyDisconnectAll() {\n        stopServices()\n\n        // Clear all sessions and peers\n        let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) {\n            let entries = activeTransfers.map { ($0.key, $0.value.workItems) }\n            peers.removeAll()\n            incomingFragments.removeAll()\n            fragmentMetadata.removeAll()\n            activeTransfers.removeAll()\n            // Also clear pending message queues to avoid stale state across sessions\n            pendingMessagesAfterHandshake.removeAll()\n            pendingNoisePayloadsAfterHandshake.removeAll()\n            pendingDirectedRelays.removeAll()\n            return entries\n        }\n\n        for entry in cancelledTransfers {\n            entry.items.forEach { $0.cancel() }\n            TransferProgressManager.shared.cancel(id: entry.id)\n        }\n\n        // Clear processed messages\n        messageDeduplicator.reset()\n\n        // Clear peripheral references (synchronized access to avoid races with BLE callbacks)\n        bleQueue.sync {\n            peripherals.removeAll()\n            peerToPeripheralUUID.removeAll()\n            subscribedCentrals.removeAll()\n            centralToPeerID.removeAll()\n            centralSubscriptionRateLimits.removeAll()\n        }\n        meshTopology.reset()\n    }\n    \n    // MARK: Connectivity and peers\n    \n    func isPeerConnected(_ peerID: PeerID) -> Bool {\n        // Accept both 16-hex short IDs and 64-hex Noise keys\n        let shortID = peerID.toShort()\n        return collectionsQueue.sync { peers[shortID]?.isConnected ?? false }\n    }\n\n    func isPeerReachable(_ peerID: PeerID) -> Bool {\n        // Accept both 16-hex short IDs and 64-hex Noise keys\n        let shortID = peerID.toShort()\n        return collectionsQueue.sync {\n            // Must be mesh-attached: at least one live direct link to the mesh\n            let meshAttached = peers.values.contains { $0.isConnected }\n            guard let info = peers[shortID] else { return false }\n            if info.isConnected { return true }\n            guard meshAttached else { return false }\n            // Apply reachability retention window\n            let isVerified = info.isVerifiedNickname\n            let retention: TimeInterval = isVerified ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds\n            return Date().timeIntervalSince(info.lastSeen) <= retention\n        }\n    }\n\n    func peerNickname(peerID: PeerID) -> String? {\n        collectionsQueue.sync {\n            guard let peer = peers[peerID], peer.isConnected else { return nil }\n            return peer.nickname\n        }\n    }\n\n    func getPeerNicknames() -> [PeerID: String] {\n        return collectionsQueue.sync {\n            let connected = peers.filter { $0.value.isConnected }\n            let tuples = connected.map { ($0.key, $0.value.nickname, true) }\n            return PeerDisplayNameResolver.resolve(tuples, selfNickname: myNickname)\n        }\n    }\n    \n    // MARK: Protocol utilities\n    \n    func getFingerprint(for peerID: PeerID) -> String? {\n        return collectionsQueue.sync {\n            return peers[peerID]?.noisePublicKey?.sha256Fingerprint()\n        }\n    }\n    \n    func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState {\n        if noiseService.hasEstablishedSession(with: peerID) {\n            return .established\n        } else if noiseService.hasSession(with: peerID) {\n            return .handshaking\n        } else {\n            return .none\n        }\n    }\n    \n    func triggerHandshake(with peerID: PeerID) {\n        initiateNoiseHandshake(with: peerID)\n    }\n    \n    func getNoiseService() -> NoiseEncryptionService {\n        return noiseService\n    }\n\n    func getCurrentBluetoothState() -> CBManagerState {\n        return centralManager?.state ?? .unknown\n    }\n\n    // MARK: Messaging\n\n    func cancelTransfer(_ transferId: String) {\n        collectionsQueue.async(flags: .barrier) { [weak self] in\n            guard let self = self else { return }\n            if let state = self.activeTransfers.removeValue(forKey: transferId) {\n                state.workItems.forEach { $0.cancel() }\n                TransferProgressManager.shared.cancel(id: transferId)\n                SecureLogger.debug(\"🛑 Cancelled transfer \\(transferId.prefix(8))…\", category: .session)\n                self.messageQueue.async { [weak self] in\n                    self?.startNextPendingTransferIfNeeded()\n                }\n            } else if let pendingIndex = self.pendingFragmentTransfers.firstIndex(where: { $0.transferId == transferId }) {\n                self.pendingFragmentTransfers.remove(at: pendingIndex)\n                TransferProgressManager.shared.cancel(id: transferId)\n                SecureLogger.debug(\"🛑 Removed pending transfer \\(transferId.prefix(8))… before start\", category: .session)\n            }\n        }\n    }\n    \n    // Transport protocol conformance helper: simplified public message send\n    func sendMessage(_ content: String, mentions: [String]) {\n        // Delegate to the full API with default routing\n        sendMessage(content, mentions: mentions, to: nil, messageID: nil, timestamp: nil)\n    }\n\n    func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {\n        sendMessage(content, mentions: mentions, to: nil, messageID: messageID, timestamp: timestamp)\n    }\n    \n    func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {\n        sendPrivateMessage(content, to: peerID, messageID: messageID)\n    }\n\n    func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {\n        messageQueue.async { [weak self] in\n            guard let self = self else { return }\n            guard let payload = filePacket.encode() else {\n                SecureLogger.error(\"❌ Failed to encode file packet for broadcast\", category: .session)\n                return\n            }\n\n            let packet = BitchatPacket(\n                type: MessageType.fileTransfer.rawValue,\n                senderID: self.myPeerIDData,\n                recipientID: nil,\n                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                payload: payload,\n                signature: nil,\n                ttl: self.messageTTL,\n                version: 2\n            )\n\n            let senderHex = packet.senderID.hexEncodedString()\n            let dedupID = \"\\(senderHex)-\\(packet.timestamp)-\\(packet.type)\"\n            self.messageDeduplicator.markProcessed(dedupID)\n\n            SecureLogger.debug(\"📁 Broadcasting file transfer payload bytes=\\(payload.count)\", category: .session)\n            self.broadcastPacket(packet, transferId: transferId)\n            self.gossipSyncManager?.onPublicPacketSeen(packet)\n        }\n    }\n\n    func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {\n        messageQueue.async { [weak self] in\n            guard let self = self else { return }\n            guard let payload = filePacket.encode() else {\n                SecureLogger.error(\"❌ Failed to encode file packet for private send\", category: .session)\n                return\n            }\n            // Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility\n            // This ensures 64-hex Noise keys are converted to the canonical routing format\n            let targetID = peerID.toShort()\n            guard let recipientData = Data(hexString: targetID.id) else {\n                SecureLogger.error(\"❌ Invalid recipient peer ID for file transfer: \\(peerID)\", category: .session)\n                return\n            }\n\n            var packet = BitchatPacket(\n                type: MessageType.fileTransfer.rawValue,\n                senderID: self.myPeerIDData,\n                recipientID: recipientData,\n                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                payload: payload,\n                signature: nil,\n                ttl: self.messageTTL,\n                version: 2\n            )\n\n            if let signed = self.noiseService.signPacket(packet) {\n                packet = signed\n            }\n\n            SecureLogger.debug(\"📁 Sending private file transfer to \\(peerID.id.prefix(8))… bytes=\\(payload.count)\", category: .session)\n            self.broadcastPacket(packet, transferId: transferId)\n        }\n    }\n\n    \n    func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {\n        // Create typed payload: [type byte] + [message ID]\n        var payload = Data([NoisePayloadType.readReceipt.rawValue])\n        payload.append(contentsOf: receipt.originalMessageID.utf8)\n\n        if noiseService.hasEstablishedSession(with: peerID) {\n            SecureLogger.debug(\"📤 Sending READ receipt for message \\(receipt.originalMessageID) to \\(peerID)\", category: .session)\n            do {\n                let encrypted = try noiseService.encrypt(payload, for: peerID)\n                let packet = BitchatPacket(\n                    type: MessageType.noiseEncrypted.rawValue,\n                    senderID: myPeerIDData,\n                    recipientID: Data(hexString: peerID.id),\n                    timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                    payload: encrypted,\n                    signature: nil,\n                    ttl: messageTTL\n                )\n                broadcastPacket(packet)\n            } catch {\n                SecureLogger.error(\"Failed to send read receipt: \\(error)\")\n            }\n        } else {\n            // Queue for after handshake and initiate if needed\n            collectionsQueue.async(flags: .barrier) { [weak self] in\n                guard let self = self else { return }\n                self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)\n            }\n            if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }\n            SecureLogger.debug(\"🕒 Queued READ receipt for \\(peerID) until handshake completes\", category: .session)\n        }\n    }\n    \n    private enum ConnectionSource {\n        case peripheral(String)\n        case central(String)\n        case unknown\n    }\n\n    private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID, connectionSource: ConnectionSource = .unknown) -> Bool {\n        let currentTime = UInt64(Date().timeIntervalSince1970 * 1000)\n\n        let isRSR = packet.isRSR\n        var skipTimestampCheck = false\n\n        if isRSR {\n            if requestSyncManager.isValidResponse(from: peerID, isRSR: true) {\n                SecureLogger.debug(\"Valid RSR packet from \\(peerID.id.prefix(8))… - skipping timestamp check\", category: .security)\n                skipTimestampCheck = true\n            } else {\n                SecureLogger.warning(\"Invalid or unsolicited RSR packet from \\(peerID.id.prefix(8))… - rejecting\", category: .security)\n                return false\n            }\n        }\n\n        if !skipTimestampCheck {\n            let maxSkew: UInt64 = 120_000\n            let packetTime = packet.timestamp\n            let skew = (packetTime > currentTime) ? (packetTime - currentTime) : (currentTime - packetTime)\n\n            if skew > maxSkew {\n                SecureLogger.warning(\"Packet timestamp skewed by \\(skew)ms (max \\(maxSkew)ms) from \\(peerID.id.prefix(8))…\", category: .security)\n                return false\n            }\n        }\n\n        return true\n    }\n\n    // MARK: - Packet Broadcasting\n    \n    private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {\n        // Apply route if recipient exists (centralized route application)\n        let packetToSend: BitchatPacket\n        if let recipientPeerID = PeerID(hexData: packet.recipientID) {\n            packetToSend = applyRouteIfAvailable(packet, to: recipientPeerID)\n        } else {\n            packetToSend = packet\n        }\n        \n        // Encode once using a small per-type padding policy, then delegate by type\n        let padForBLE = padPolicy(for: packetToSend.type)\n        if packetToSend.type == MessageType.fileTransfer.rawValue {\n            sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId)\n            return\n        }\n        guard let data = packetToSend.toBinaryData(padding: padForBLE) else {\n            SecureLogger.error(\"❌ Failed to convert packet to binary data\", category: .session)\n            return\n        }\n        if packetToSend.type == MessageType.noiseEncrypted.rawValue {\n            sendEncrypted(packetToSend, data: data, pad: padForBLE)\n            return\n        }\n        sendGenericBroadcast(packetToSend, data: data, pad: padForBLE)\n    }\n\n    // MARK: - Broadcast helpers (single responsibility)\n    private func padPolicy(for type: UInt8) -> Bool {\n        switch MessageType(rawValue: type) {\n        case .noiseEncrypted, .noiseHandshake:\n            return true\n        case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:\n            return false\n        }\n    }\n\n    private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) {\n        guard let recipientPeerID = PeerID(hexData: packet.recipientID) else { return }\n        var sentEncrypted = false\n\n        let outboundPriority = priority(for: packet, data: data)\n\n        // Per-link limits for the specific peer\n        var peripheralMaxLen: Int?\n        if let perUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }) {\n            if let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[perUUID] : bleQueue.sync(execute: { peripherals[perUUID] }) {\n                peripheralMaxLen = state.peripheral.maximumWriteValueLength(for: .withoutResponse)\n            }\n        }\n        var centralMaxLen: Int?\n        do {\n            let (centrals, mapping) = snapshotSubscribedCentrals()\n            if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == recipientPeerID }) {\n                centralMaxLen = central.maximumUpdateValueLength\n            }\n        }\n        if let pm = peripheralMaxLen, data.count > pm {\n            let overhead = 13 + 8 + 8 + 13\n            let chunk = max(64, pm - overhead)\n            sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)\n            return\n        }\n        if let cm = centralMaxLen, data.count > cm {\n            let overhead = 13 + 8 + 8 + 13\n            let chunk = max(64, cm - overhead)\n            sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)\n            return\n        }\n\n        // Direct write via peripheral link\n        if let peripheralUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }),\n           let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }),\n           state.isConnected,\n           let characteristic = state.characteristic {\n            writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: outboundPriority)\n            sentEncrypted = true\n        }\n\n        // Notify via central link (dual-role)\n        if let characteristic = characteristic, !sentEncrypted {\n            let (centrals, mapping) = snapshotSubscribedCentrals()\n            for central in centrals where mapping[central.identifier.uuidString] == recipientPeerID {\n                let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false\n                if success { sentEncrypted = true; break }\n                enqueuePendingNotification(data: data, centrals: [central], context: \"encrypted\")\n            }\n        }\n\n        if !sentEncrypted {\n            // Flood as last resort with recipient set; link aware\n            sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: recipientPeerID)\n        }\n    }\n\n    private func sendGenericBroadcast(_ packet: BitchatPacket, data: Data, pad: Bool) {\n        sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: nil)\n    }\n\n    private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {\n        collectionsQueue.async(flags: .barrier) { [weak self] in\n            guard let self = self else { return }\n            if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount {\n                self.pendingNotifications.append((data: data, centrals: centrals))\n                SecureLogger.debug(\"📋 Queued \\(context) packet for retry (pending=\\(self.pendingNotifications.count))\", category: .session)\n                return\n            }\n\n            if attempt >= TransportConfig.bleNotificationRetryMaxAttempts {\n                SecureLogger.error(\"❌ Dropping \\(context) packet after exhausting retry window (pending=\\(self.pendingNotifications.count))\", category: .session)\n                return\n            }\n\n            let backoff = TransportConfig.bleNotificationRetryDelayMs * max(1, attempt + 1)\n            let deadline = DispatchTime.now() + .milliseconds(backoff)\n            self.messageQueue.asyncAfter(deadline: deadline) { [weak self] in\n                self?.enqueuePendingNotification(data: data, centrals: centrals, context: context, attempt: attempt + 1)\n            }\n        }\n    }\n\n    private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) {\n        // Determine last-hop link for this message to avoid echoing back\n        let messageID = makeMessageID(for: packet)\n        let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }\n        let directedPeerHint: PeerID? = {\n            if let explicit = directedOnlyPeer { return explicit }\n            if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty {\n                return recipient\n            }\n            return nil\n        }()\n        let outboundPriority = priority(for: packet, data: data)\n\n        let states = snapshotPeripheralStates()\n        var minCentralWriteLen: Int?\n        for s in states where s.isConnected {\n            let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse)\n            minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m\n        }\n        var snapshotCentrals: [CBCentral] = []\n        if let _ = characteristic {\n            let (centrals, _) = snapshotSubscribedCentrals()\n            snapshotCentrals = centrals\n        }\n        var minNotifyLen: Int?\n        if !snapshotCentrals.isEmpty {\n            minNotifyLen = snapshotCentrals.map { $0.maximumUpdateValueLength }.min()\n        }\n        // Avoid re-fragmenting fragment packets\n        if packet.type != MessageType.fragment.rawValue,\n           let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min(),\n           data.count > minLen {\n            let overhead = 13 + 8 + 8 + 13\n            let chunk = max(64, minLen - overhead)\n            sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)\n            return\n        }\n        // Build link lists and apply K-of-N fanout for broadcasts; always exclude ingress link\n        let connectedPeripheralIDs: [String] = states.filter { $0.isConnected }.map { $0.peripheral.identifier.uuidString }\n        let subscribedCentrals: [CBCentral]\n        var centralIDs: [String] = []\n        if let _ = characteristic {\n            let (centrals, _) = snapshotSubscribedCentrals()\n            subscribedCentrals = centrals\n            centralIDs = centrals.map { $0.identifier.uuidString }\n        } else {\n            subscribedCentrals = []\n        }\n\n        // Exclude ingress link\n        var allowedPeripheralIDs = connectedPeripheralIDs\n        var allowedCentralIDs = centralIDs\n        if let ingress = ingressLink {\n            switch ingress {\n            case .peripheral(let id):\n                allowedPeripheralIDs.removeAll { $0 == id }\n            case .central(let id):\n                allowedCentralIDs.removeAll { $0 == id }\n            }\n        }\n\n        // For broadcast (no directed peer) and non-fragment, choose a subset deterministically\n        // Special-case control/presence messages: do NOT subset to maximize immediate coverage\n        var selectedPeripheralIDs = Set(allowedPeripheralIDs)\n        var selectedCentralIDs = Set(allowedCentralIDs)\n        if directedPeerHint == nil\n            && packet.type != MessageType.fragment.rawValue\n            && packet.type != MessageType.announce.rawValue\n            && packet.type != MessageType.requestSync.rawValue {\n            let kp = subsetSizeForFanout(allowedPeripheralIDs.count)\n            let kc = subsetSizeForFanout(allowedCentralIDs.count)\n            selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)\n            selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID)\n        }\n\n        // If directed and we currently have no links to forward on, spool for a short window\n        if let only = directedPeerHint,\n           selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,\n           (packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {\n            spoolDirectedPacket(packet, recipientPeerID: only)\n        }\n\n        // Writes to selected connected peripherals\n        for s in states where s.isConnected {\n            let pid = s.peripheral.identifier.uuidString\n            guard selectedPeripheralIDs.contains(pid) else { continue }\n            if let ch = s.characteristic {\n                writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)\n            }\n        }\n        // Notify selected subscribed centrals\n        if let ch = characteristic {\n            let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) }\n            if !targets.isEmpty {\n                let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false\n                if !success {\n                    // Notification queue full - queue for retry to prevent silent packet loss\n                    // This is critical for fragment delivery reliability\n                    let context = packet.type == MessageType.fragment.rawValue ? \"fragment\" : \"broadcast\"\n                    enqueuePendingNotification(data: data, centrals: targets, context: context)\n                }\n            }\n        }\n    }\n\n    // Directed send helper (unicast to a specific peerID) without altering packet contents\n    private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {\n        guard let data = packet.toBinaryData(padding: false) else { return }\n        sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)\n    }\n\n    // MARK: - Directed store-and-forward\n    private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: PeerID) {\n        let msgID = makeMessageID(for: packet)\n        collectionsQueue.async(flags: .barrier) { [weak self] in\n            guard let self = self else { return }\n            var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:]\n            if byMsg[msgID] == nil {\n                byMsg[msgID] = (packet: packet, enqueuedAt: Date())\n                self.pendingDirectedRelays[recipientPeerID] = byMsg\n                SecureLogger.debug(\"🧳 Spooling directed packet for \\(recipientPeerID) mid=\\(msgID.prefix(8))…\", category: .session)\n            }\n        }\n    }\n\n    private func flushDirectedSpool() {\n        // Move items out and attempt broadcast; if still no links, they'll be re-spooled\n        let toSend: [(String, BitchatPacket)] = collectionsQueue.sync(flags: .barrier) {\n            var out: [(String, BitchatPacket)] = []\n            let now = Date()\n            for (recipient, dict) in pendingDirectedRelays {\n                for (_, entry) in dict {\n                    if now.timeIntervalSince(entry.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds {\n                        out.append((recipient.id, entry.packet))\n                    }\n                }\n                // Clear recipient bucket; items will be re-spooled if still no links\n                pendingDirectedRelays.removeValue(forKey: recipient)\n            }\n            return out\n        }\n        guard !toSend.isEmpty else { return }\n        for (_, packet) in toSend {\n            messageQueue.async { [weak self] in self?.broadcastPacket(packet) }\n        }\n    }\n\n    private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) {\n        if peerID == myPeerID && packet.ttl != 0 { return }\n\n        var accepted = false\n        var senderNickname = \"\"\n\n        let peersSnapshot = collectionsQueue.sync { peers }\n\n        if peerID == myPeerID {\n            accepted = true\n            senderNickname = myNickname\n        } else if let info = peersSnapshot[peerID], info.isVerifiedNickname {\n            accepted = true\n            senderNickname = info.nickname\n            let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)\n            if hasCollision {\n                senderNickname += \"#\" + String(peerID.id.prefix(4))\n            }\n        } else if let info = peersSnapshot[peerID], info.isConnected {\n            accepted = true\n            senderNickname = info.nickname.isEmpty ? \"anon\" + String(peerID.id.prefix(4)) : info.nickname\n            let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)\n            if hasCollision {\n                senderNickname += \"#\" + String(peerID.id.prefix(4))\n            }\n        } else if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {\n            let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)\n            for candidate in candidates {\n                if let signingKey = candidate.signingPublicKey,\n                   noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {\n                    accepted = true\n                    if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) {\n                        senderNickname = social.localPetname ?? social.claimedNickname\n                    } else {\n                        senderNickname = \"anon\" + String(peerID.id.prefix(4))\n                    }\n                    break\n                }\n            }\n        }\n\n        guard accepted else {\n            SecureLogger.warning(\"🚫 Dropping file transfer from unverified or unknown peer \\(peerID.id.prefix(8))…\", category: .security)\n            return\n        }\n\n        // Skip directed packets that are not intended for us\n        if let recipient = packet.recipientID {\n            if PeerID(hexData: recipient) != myPeerID && !recipient.allSatisfy({ $0 == 0xFF }) {\n                return\n            }\n        }\n\n        if let recipient = packet.recipientID,\n           recipient.allSatisfy({ $0 == 0xFF }) {\n            gossipSyncManager?.onPublicPacketSeen(packet)\n        } else if packet.recipientID == nil {\n            gossipSyncManager?.onPublicPacketSeen(packet)\n        }\n\n        guard let filePacket = BitchatFilePacket.decode(packet.payload) else {\n            SecureLogger.error(\"❌ Failed to decode file transfer payload\", category: .session)\n            return\n        }\n\n        guard FileTransferLimits.isValidPayload(filePacket.content.count) else {\n            SecureLogger.warning(\"🚫 Dropping file transfer exceeding size cap (\\(filePacket.content.count) bytes)\", category: .security)\n            return\n        }\n\n        guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else {\n            SecureLogger.warning(\"🚫 MIME REJECT: '\\(filePacket.mimeType ?? \"<empty>\")' not supported. Size=\\(filePacket.content.count)b from \\(peerID.id.prefix(8))...\", category: .security)\n            return\n        }\n\n        // Validate content matches declared MIME type (magic byte check)\n        guard mime.matches(data: filePacket.content) else {\n            let prefix = filePacket.content.prefix(20).map { String(format: \"%02x\", $0) }.joined(separator: \" \")\n            SecureLogger.warning(\"🚫 MAGIC REJECT: MIME='\\(mime)' size=\\(filePacket.content.count)b prefix=[\\(prefix)] from \\(peerID.id.prefix(8))...\", category: .security)\n            return\n        }\n\n        // BCH-01-002: Enforce storage quota before saving\n        enforceIncomingFilesQuota(reservingBytes: filePacket.content.count)\n\n        let fallbackExt = mime.defaultExtension\n        let subdirectory: String\n        switch mime.category {\n        case .audio:\n            subdirectory = \"voicenotes/incoming\"\n        case .image:\n            subdirectory = \"images/incoming\"\n        case .file:\n            subdirectory = \"files/incoming\"\n        }\n\n        guard let destination = saveIncomingFile(\n            data: filePacket.content,\n            preferredName: filePacket.fileName,\n            subdirectory: subdirectory,\n            fallbackExtension: fallbackExt,\n            defaultPrefix: mime.category.rawValue\n        ) else {\n            return\n        }\n\n        let marker: String\n        let fileName = destination.lastPathComponent\n        switch mime.category {\n        case .audio:\n            marker = \"[voice] \\(fileName)\"\n        case .image:\n            marker = \"[image] \\(fileName)\"\n        case .file:\n            marker = \"[file] \\(fileName)\"\n        }\n\n        let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID\n\n        if isPrivateMessage {\n            updatePeerLastSeen(peerID)\n        }\n\n        let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)\n        let message = BitchatMessage(\n            sender: senderNickname,\n            content: marker,\n            timestamp: ts,\n            isRelay: false,\n            originalSender: nil,\n            isPrivate: isPrivateMessage,\n            recipientNickname: nil,\n            senderPeerID: peerID\n        )\n\n        SecureLogger.debug(\"📁 Stored incoming media from \\(peerID.id.prefix(8))… -> \\(destination.lastPathComponent)\", category: .session)\n\n        notifyUI { [weak self] in\n            self?.delegate?.didReceiveMessage(message)\n        }\n    }\n    \n    func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {\n        SecureLogger.debug(\"🔔 sendFavoriteNotification called - peerID: \\(peerID), isFavorite: \\(isFavorite)\", category: .session)\n        \n        // Include Nostr public key in the notification\n        var content = isFavorite ? \"[FAVORITED]\" : \"[UNFAVORITED]\"\n        \n        // Add our Nostr public key if available\n        if let myNostrIdentity = try? idBridge.getCurrentNostrIdentity() {\n            content += \":\" + myNostrIdentity.npub\n            SecureLogger.debug(\"📝 Sending favorite notification with Nostr npub: \\(myNostrIdentity.npub)\", category: .session)\n        }\n        \n        SecureLogger.debug(\"📤 Sending favorite notification to \\(peerID): \\(content)\", category: .session)\n        sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString)\n    }\n    \n    func sendBroadcastAnnounce() {\n        sendAnnounce()\n    }\n    \n    func sendDeliveryAck(for messageID: String, to peerID: PeerID) {\n        // Create typed payload: [type byte] + [message ID]\n        var payload = Data([NoisePayloadType.delivered.rawValue])\n        payload.append(contentsOf: messageID.utf8)\n\n        if noiseService.hasEstablishedSession(with: peerID) {\n            do {\n                let encrypted = try noiseService.encrypt(payload, for: peerID)\n                let packet = BitchatPacket(\n                    type: MessageType.noiseEncrypted.rawValue,\n                    senderID: myPeerIDData,\n                    recipientID: Data(hexString: peerID.id),\n                    timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                    payload: encrypted,\n                    signature: nil,\n                    ttl: messageTTL\n                )\n                broadcastPacket(packet)\n            } catch {\n                SecureLogger.error(\"Failed to send delivery ACK: \\(error)\")\n            }\n        } else {\n            // Queue for after handshake and initiate if needed\n            collectionsQueue.async(flags: .barrier) { [weak self] in\n                guard let self = self else { return }\n                self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)\n            }\n            if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }\n            SecureLogger.debug(\"🕒 Queued DELIVERED ack for \\(peerID) until handshake completes\", category: .session)\n        }\n    }\n\n    private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) {\n        _ = collectionsQueue.sync(flags: .barrier) {\n            // Remove the peer when they leave\n            peers.removeValue(forKey: peerID)\n        }\n        // Remove any stored announcement for sync purposes\n        gossipSyncManager?.removeAnnouncementForPeer(peerID)\n        // Send on main thread\n        notifyUI { [weak self] in\n            guard let self = self else { return }\n            \n            // Get current peer list (after removal)\n            let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }\n            \n            self.delegate?.didDisconnectFromPeer(peerID)\n            self.delegate?.didUpdatePeerList(currentPeerIDs)\n        }\n    }\n    \n    // MARK: - Helper Functions\n\n    private func applicationFilesDirectory() throws -> URL {\n        let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)\n        let filesDir = base.appendingPathComponent(\"files\", isDirectory: true)\n        try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)\n        return filesDir\n    }\n\n    private func sanitizeFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {\n        var candidate = name ?? \"\"\n\n        // Security: Remove null bytes (path traversal vector)\n        candidate = candidate.replacingOccurrences(of: \"\\0\", with: \"\")\n\n        // Security: Unicode normalization prevents fullwidth character bypass\n        candidate = candidate.precomposedStringWithCanonicalMapping\n\n        // Security: Remove ALL path separators (not just strip last component)\n        candidate = candidate.replacingOccurrences(of: \"/\", with: \"_\")\n        candidate = candidate.replacingOccurrences(of: \"\\\\\", with: \"_\")\n\n        // Security: Remove control characters and dangerous filesystem chars\n        let invalid = CharacterSet(charactersIn: \"<>:\\\"|?*\\0\").union(.controlCharacters)\n        candidate = candidate.components(separatedBy: invalid).joined(separator: \"_\")\n\n        candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines)\n        if candidate.isEmpty { candidate = defaultName }\n\n        // Security: Reject dotfiles (hidden file attacks)\n        if candidate.hasPrefix(\".\") {\n            candidate = \"_\" + candidate\n        }\n\n        // Truncate while preserving extension\n        if candidate.count > 120 {\n            let ext = (candidate as NSString).pathExtension\n            let base = (candidate as NSString).deletingPathExtension\n            if ext.isEmpty {\n                candidate = String(candidate.prefix(120))\n            } else {\n                let maxBase = max(10, 120 - ext.count - 1)\n                candidate = String(base.prefix(maxBase)) + \".\" + ext\n            }\n        }\n\n        if let fallbackExtension = fallbackExtension, (candidate as NSString).pathExtension.isEmpty {\n            candidate += \".\\(fallbackExtension)\"\n        }\n\n        if candidate.isEmpty { candidate = defaultName }\n        return candidate\n    }\n\n    private func uniqueFileURL(in directory: URL, fileName: String) -> URL {\n        var candidate = directory.appendingPathComponent(fileName)\n\n        // Security: Validate path doesn't escape directory\n        if !candidate.path.hasPrefix(directory.path) {\n            SecureLogger.warning(\"⚠️ Path traversal blocked: \\(fileName)\", category: .security)\n            return directory.appendingPathComponent(\"blocked_\\(UUID().uuidString)\")\n        }\n\n        if !FileManager.default.fileExists(atPath: candidate.path) {\n            return candidate\n        }\n\n        let baseName = (fileName as NSString).deletingPathExtension\n        let ext = (fileName as NSString).pathExtension\n        var counter = 1\n\n        // Limit iterations to prevent DoS\n        while counter < 100 {\n            let newName = ext.isEmpty ? \"\\(baseName) (\\(counter))\" : \"\\(baseName) (\\(counter)).\\(ext)\"\n            candidate = directory.appendingPathComponent(newName)\n\n            // Validate each iteration\n            guard candidate.path.hasPrefix(directory.path) else {\n                return directory.appendingPathComponent(\"blocked_\\(UUID().uuidString)\")\n            }\n\n            if !FileManager.default.fileExists(atPath: candidate.path) {\n                return candidate\n            }\n            counter += 1\n        }\n\n        // Fallback: UUID to guarantee uniqueness\n        return directory.appendingPathComponent(\"\\(baseName)_\\(UUID().uuidString).\\(ext.isEmpty ? \"dat\" : ext)\")\n    }\n\n    private func saveIncomingFile(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String) -> URL? {\n        do {\n            let base = try applicationFilesDirectory().appendingPathComponent(subdirectory, isDirectory: true)\n            try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)\n            let timestamp = mediaDateFormatter.string(from: Date())\n            let defaultName = \"\\(defaultPrefix)_\\(timestamp)\"\n            let sanitized = sanitizeFileName(preferredName, defaultName: defaultName, fallbackExtension: fallbackExtension)\n            let destination = uniqueFileURL(in: base, fileName: sanitized)\n            try data.write(to: destination, options: .atomic)\n            return destination\n        } catch {\n            SecureLogger.error(\"❌ Failed to persist incoming media: \\(error)\", category: .session)\n            return nil\n        }\n    }\n\n    // MARK: - Storage Quota Management (BCH-01-002)\n\n    /// Maximum total storage for incoming files (100 MB)\n    private static let incomingFilesQuota: Int64 = 100 * 1024 * 1024\n\n    /// Enforces storage quota for incoming files by deleting oldest files when quota is exceeded.\n    /// Call before saving a new incoming file.\n    private func enforceIncomingFilesQuota(reservingBytes: Int) {\n        do {\n            let base = try applicationFilesDirectory()\n            let incomingDirs = [\n                base.appendingPathComponent(\"voicenotes/incoming\", isDirectory: true),\n                base.appendingPathComponent(\"images/incoming\", isDirectory: true),\n                base.appendingPathComponent(\"files/incoming\", isDirectory: true)\n            ]\n\n            // Gather all incoming files with their sizes and modification dates\n            var allFiles: [(url: URL, size: Int64, modified: Date)] = []\n            let fileManager = FileManager.default\n\n            for dir in incomingDirs {\n                guard fileManager.fileExists(atPath: dir.path) else { continue }\n                guard let contents = try? fileManager.contentsOfDirectory(\n                    at: dir,\n                    includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],\n                    options: [.skipsHiddenFiles]\n                ) else { continue }\n\n                for fileURL in contents {\n                    guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),\n                          let size = attrs.fileSize,\n                          let modified = attrs.contentModificationDate else { continue }\n                    allFiles.append((url: fileURL, size: Int64(size), modified: modified))\n                }\n            }\n\n            // Calculate current usage\n            let currentUsage = allFiles.reduce(0) { $0 + $1.size }\n            let targetUsage = Self.incomingFilesQuota - Int64(reservingBytes)\n\n            guard currentUsage > targetUsage else { return }\n\n            // Sort by modification date (oldest first) and delete until under quota\n            let sortedFiles = allFiles.sorted { $0.modified < $1.modified }\n            var freedSpace: Int64 = 0\n            let needToFree = currentUsage - targetUsage\n\n            for file in sortedFiles {\n                guard freedSpace < needToFree else { break }\n                do {\n                    try fileManager.removeItem(at: file.url)\n                    freedSpace += file.size\n                    SecureLogger.debug(\"🗑️ BCH-01-002: Deleted old incoming file to free space: \\(file.url.lastPathComponent)\", category: .security)\n                } catch {\n                    SecureLogger.warning(\"⚠️ Failed to delete old file for quota: \\(error)\", category: .security)\n                }\n            }\n\n            if freedSpace > 0 {\n                SecureLogger.info(\"📊 BCH-01-002: Freed \\(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota\", category: .security)\n            }\n        } catch {\n            SecureLogger.warning(\"⚠️ Could not enforce storage quota: \\(error)\", category: .security)\n        }\n    }\n\n    private func sendAnnounce(forceSend: Bool = false) {\n        // Throttle announces to prevent flooding\n        let now = Date()\n        let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent)\n        \n        // Even forced sends should respect a minimum interval to avoid overwhelming BLE\n        let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval\n        \n        if timeSinceLastAnnounce < minInterval {\n            // Skipping announce (rate limited)\n            return\n        }\n        lastAnnounceSent = now\n        \n        // Reduced logging - only log errors, not every announce\n        \n        // Create announce payload with both noise and signing public keys\n        let noisePub = noiseService.getStaticPublicKeyData()  // For noise handshakes and peer identification\n        let signingPub = noiseService.getSigningPublicKeyData()  // For signature verification\n        \n        let connectedPeerIDs: [Data] = collectionsQueue.sync {\n            peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData }\n        }\n        \n        let announcement = AnnouncementPacket(\n            nickname: myNickname,\n            noisePublicKey: noisePub,\n            signingPublicKey: signingPub,\n            directNeighbors: connectedPeerIDs\n        )\n        \n        guard let payload = announcement.encode() else {\n            SecureLogger.error(\"❌ Failed to encode announce packet\", category: .session)\n            return\n        }\n        \n        // Create packet with signature using the noise private key\n        let packet = BitchatPacket(\n            type: MessageType.announce.rawValue,\n            senderID: myPeerIDData,\n            recipientID: nil,\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: payload,\n            signature: nil, // Will be set by signPacket below\n            ttl: messageTTL\n        )\n        \n        // Sign the packet using the noise private key\n        guard let signedPacket = noiseService.signPacket(packet) else {\n            SecureLogger.error(\"❌ Failed to sign announce packet\", category: .security)\n            return\n        }\n        \n        // Call directly if on messageQueue, otherwise dispatch\n        if DispatchQueue.getSpecific(key: messageQueueKey) != nil {\n            broadcastPacket(signedPacket)\n        } else {\n            messageQueue.async { [weak self] in\n                self?.broadcastPacket(signedPacket)\n            }\n        }\n        // Ensure our own announce is included in sync state\n        gossipSyncManager?.onPublicPacketSeen(signedPacket)\n    }\n\n    // MARK: QR Verification over Noise\n    \n    func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {\n        let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)\n        sendNoisePayload(payload, to: peerID)\n    }\n\n    func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {\n        guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }\n        sendNoisePayload(payload, to: peerID)\n    }\n}\n\n// MARK: - GossipSyncManager Delegate\nextension BLEService: GossipSyncManager.Delegate {\n    func sendPacket(_ packet: BitchatPacket) {\n        broadcastPacket(packet)\n    }\n\n    func sendPacket(to peerID: PeerID, packet: BitchatPacket) {\n        sendPacketDirected(packet, to: peerID)\n    }\n\n    func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {\n        return noiseService.signPacket(packet) ?? packet\n    }\n    \n    func getConnectedPeers() -> [PeerID] {\n        return collectionsQueue.sync {\n            peers.values.compactMap { $0.isConnected ? $0.peerID : nil }\n        }\n    }\n}\n\n// MARK: - CBCentralManagerDelegate\n\nextension BLEService: CBCentralManagerDelegate {\n    #if os(iOS)\n    func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {\n        let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []\n        let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []\n        let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]\n        let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool\n\n        SecureLogger.info(\n            \"♻️ Central restore: peripherals=\\(restoredPeripherals.count) services=\\(restoredServices.count) allowDuplicates=\\(String(describing: allowDuplicates))\",\n            category: .session\n        )\n\n        for peripheral in restoredPeripherals {\n            let identifier = peripheral.identifier.uuidString\n            peripheral.delegate = self\n            let existing = peripherals[identifier]\n            let assembler = existing?.assembler ?? NotificationStreamAssembler()\n            let characteristic = existing?.characteristic\n            let peerID = existing?.peerID\n            let wasConnecting = existing?.isConnecting ?? false\n            let wasConnected = existing?.isConnected ?? false\n\n            let restoredState = PeripheralState(\n                peripheral: peripheral,\n                characteristic: characteristic,\n                peerID: peerID,\n                isConnecting: wasConnecting || peripheral.state == .connecting,\n                isConnected: wasConnected || peripheral.state == .connected,\n                lastConnectionAttempt: existing?.lastConnectionAttempt,\n                assembler: assembler\n            )\n            peripherals[identifier] = restoredState\n        }\n\n        captureBluetoothStatus(context: \"central-restore\")\n\n        if central.state == .poweredOn {\n            startScanning()\n        }\n    }\n    #endif\n\n    func centralManagerDidUpdateState(_ central: CBCentralManager) {\n        // Notify delegate about state change on main thread\n        Task { @MainActor in\n            self.delegate?.didUpdateBluetoothState(central.state)\n        }\n\n        switch central.state {\n        case .poweredOn:\n            // Start scanning - use allow duplicates for faster discovery when active\n            startScanning()\n\n        case .poweredOff:\n            // Bluetooth was turned off - stop scanning and clean up connection state\n            SecureLogger.info(\"📴 Bluetooth powered off - cleaning up central state\", category: .session)\n            central.stopScan()\n            // Mark all peripheral connections as disconnected (they are now invalid)\n            let peerIDs: [PeerID] = peripherals.compactMap { $0.value.peerID }\n            for state in peripherals.values {\n                central.cancelPeripheralConnection(state.peripheral)\n            }\n            peripherals.removeAll()\n            peerToPeripheralUUID.removeAll()\n            // Notify UI of disconnections\n            for peerID in peerIDs {\n                notifyUI { [weak self] in\n                    self?.notifyPeerDisconnectedDebounced(peerID)\n                }\n            }\n\n        case .unauthorized:\n            // User denied Bluetooth permission\n            SecureLogger.warning(\"🚫 Bluetooth unauthorized - user denied permission\", category: .session)\n            central.stopScan()\n            peripherals.removeAll()\n            peerToPeripheralUUID.removeAll()\n\n        case .unsupported:\n            // Device doesn't support BLE\n            SecureLogger.error(\"❌ Bluetooth LE not supported on this device\", category: .session)\n\n        case .resetting:\n            // Bluetooth stack is resetting - will get another state update when done\n            SecureLogger.info(\"🔄 Bluetooth stack resetting...\", category: .session)\n\n        case .unknown:\n            // Initial state before we know the actual state\n            SecureLogger.debug(\"❓ Bluetooth state unknown (initializing)\", category: .session)\n\n        @unknown default:\n            SecureLogger.warning(\"⚠️ Unknown Bluetooth state: \\(central.state.rawValue)\", category: .session)\n        }\n    }\n    \n    private func startScanning() {\n        guard let central = centralManager,\n              central.state == .poweredOn,\n              !central.isScanning else { return }\n        \n        // Use allow duplicates = true for faster discovery in foreground\n        // This gives us discovery events immediately instead of coalesced\n        #if os(iOS)\n        let allowDuplicates = isAppActive  // Use our tracked state (thread-safe)\n        #else\n        let allowDuplicates = true  // macOS doesn't have background restrictions\n        #endif\n        \n        central.scanForPeripherals(\n                withServices: [BLEService.serviceUUID],\n            options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates]\n        )\n        \n        // Started BLE scanning\n    }\n    \n    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {\n        let peripheralID = peripheral.identifier.uuidString\n        let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + \"…\")\n        let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true\n        let rssiValue = RSSI.intValue\n        \n        // Skip if peripheral is not connectable (per advertisement data)\n        guard isConnectable else { return }\n\n        // Skip immediate connect if signal too weak for current conditions; enqueue instead\n        if rssiValue <= dynamicRSSIThreshold {\n            connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date()))\n            // Keep list tidy\n            connectionCandidates.sort { (a, b) in\n                if a.rssi != b.rssi { return a.rssi > b.rssi }\n                return a.discoveredAt < b.discoveredAt\n            }\n            if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax {\n                connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax)\n            }\n            return\n        }\n        \n        // Budget: limit simultaneous central links (connected + connecting)\n        let currentCentralLinks = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count\n        if currentCentralLinks >= maxCentralLinks {\n            // Enqueue as candidate; we'll attempt later as slots open\n            connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date()))\n            // Keep candidate list tidy: prefer stronger RSSI, then recency; cap list\n            connectionCandidates.sort { (a, b) in\n                if a.rssi != b.rssi { return a.rssi > b.rssi }\n                return a.discoveredAt < b.discoveredAt\n            }\n            if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax {\n                connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax)\n            }\n            return\n        }\n\n        // Rate limit global connect attempts\n        let sinceLast = Date().timeIntervalSince(lastGlobalConnectAttempt)\n        if sinceLast < connectRateLimitInterval {\n            connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date()))\n            connectionCandidates.sort { (a, b) in\n                if a.rssi != b.rssi { return a.rssi > b.rssi }\n                return a.discoveredAt < b.discoveredAt\n            }\n            // Schedule a deferred attempt after rate-limit interval\n            let delay = connectRateLimitInterval - sinceLast + 0.05\n            bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in\n                self?.tryConnectFromQueue()\n            }\n            return\n        }\n\n        // Check if we already have this peripheral\n        if let state = peripherals[peripheralID] {\n            if state.isConnected || state.isConnecting {\n                return // Already connected or connecting\n            }\n            \n        // Add backoff for reconnection attempts\n        if let lastAttempt = state.lastConnectionAttempt {\n            let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempt)\n            if timeSinceLastAttempt < 2.0 {\n                return // Wait at least 2 seconds between connection attempts\n            }\n        }\n        }\n        \n        // Backoff if this peripheral recently timed out connection within the last 15 seconds\n        if let lastTimeout = recentConnectTimeouts[peripheralID], Date().timeIntervalSince(lastTimeout) < 15 {\n            return\n        }\n\n        // Check peripheral state - but cancel if stale\n        if peripheral.state == .connecting || peripheral.state == .connected {\n            // iOS might have stale state - force disconnect and retry\n            central.cancelPeripheralConnection(peripheral)\n            // Will retry on next discovery\n            return\n        }\n        \n        // Only log when we're actually attempting connection\n        // Discovered BLE peripheral\n        \n        // Store the peripheral and mark as connecting\n        peripherals[peripheralID] = PeripheralState(\n            peripheral: peripheral,\n            characteristic: nil,\n            peerID: nil,\n            isConnecting: true,\n            isConnected: false,\n            lastConnectionAttempt: Date(),\n            assembler: NotificationStreamAssembler()\n        )\n        peripheral.delegate = self\n        \n        // Connect to the peripheral with options for faster connection\n        SecureLogger.debug(\"📱 Connect: \\(advertisedName) [RSSI:\\(rssiValue)]\", category: .session)\n        \n        // Use connection options for faster reconnection\n        let options: [String: Any] = [\n            CBConnectPeripheralOptionNotifyOnConnectionKey: true,\n            CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,\n            CBConnectPeripheralOptionNotifyOnNotificationKey: true\n        ]\n        central.connect(peripheral, options: options)\n        lastGlobalConnectAttempt = Date()\n        \n        // Set a timeout for the connection attempt (slightly longer for reliability)\n        // Use BLE queue to mutate BLE-related state consistently\n        bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in\n            guard let self = self,\n                  let state = self.peripherals[peripheralID],\n                  state.isConnecting && !state.isConnected else { return }\n\n            // Double-check actual CBPeripheral state to avoid canceling a just-connected peripheral\n            // This prevents a race where connection completes just as timeout fires\n            guard peripheral.state != .connected else {\n                SecureLogger.debug(\"⏱️ Timeout fired but peripheral already connected: \\(advertisedName)\", category: .session)\n                return\n            }\n\n            // Connection timed out - cancel it\n            SecureLogger.debug(\"⏱️ Timeout: \\(advertisedName)\", category: .session)\n            central.cancelPeripheralConnection(peripheral)\n            self.peripherals[peripheralID] = nil\n            self.recentConnectTimeouts[peripheralID] = Date()\n            self.failureCounts[peripheralID, default: 0] += 1\n            // Try next candidate if any\n            self.tryConnectFromQueue()\n        }\n    }\n    \nfunc centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {\n        let peripheralID = peripheral.identifier.uuidString\n        \n        // Update state to connected\n        if var state = peripherals[peripheralID] {\n            state.isConnecting = false\n            state.isConnected = true\n            peripherals[peripheralID] = state\n        } else {\n            // Create new state if not found\n            peripherals[peripheralID] = PeripheralState(\n                peripheral: peripheral,\n                characteristic: nil,\n                peerID: nil,\n                isConnecting: false,\n                isConnected: true,\n                lastConnectionAttempt: nil,\n                assembler: NotificationStreamAssembler()\n            )\n        }\n        \n        // Reset backoff state on success\n        failureCounts[peripheralID] = 0\n        recentConnectTimeouts.removeValue(forKey: peripheralID)\n\n        SecureLogger.debug(\"✅ Connected: \\(peripheral.name ?? \"Unknown\") [\\(peripheralID)]\", category: .session)\n        \n        // Discover services\n        peripheral.discoverServices([BLEService.serviceUUID])\n    }\n    \n    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {\n        let peripheralID = peripheral.identifier.uuidString\n        \n        // Find the peer ID if we have it\n        let peerID = peripherals[peripheralID]?.peerID\n        \n        SecureLogger.debug(\"📱 Disconnect: \\(peerID?.id ?? peripheralID)\\(error != nil ? \" (\\(error!.localizedDescription))\" : \"\")\", category: .session)\n\n        // If disconnect carried an error (often timeout), apply short backoff to avoid thrash\n        if error != nil {\n            recentConnectTimeouts[peripheralID] = Date()\n        }\n        \n        // Clean up references\n        peripherals.removeValue(forKey: peripheralID)\n        \n        // Clean up peer mappings\n        if let peerID {\n            peerToPeripheralUUID.removeValue(forKey: peerID)\n            \n            // Do not remove peer; mark as not connected but retain for reachability\n            collectionsQueue.sync(flags: .barrier) {\n                if var info = peers[peerID] {\n                    info.isConnected = false\n                    peers[peerID] = info\n                }\n            }\n            refreshLocalTopology()\n        }\n\n        \n        // Restart scanning with allow duplicates for faster rediscovery\n        if centralManager?.state == .poweredOn {\n            // Stop and restart scanning to ensure we get fresh discovery events\n            centralManager?.stopScan()\n            bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in\n                self?.startScanning()\n            }\n        }\n        // Attempt to fill freed slot from queue\n        bleQueue.async { [weak self] in self?.tryConnectFromQueue() }\n        \n        // Notify delegate about disconnection on main thread (direct link dropped)\n        notifyUI { [weak self] in\n            guard let self = self else { return }\n            \n            // Get current peer list (after removal)\n            let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }\n            \n            if let peerID {\n                self.notifyPeerDisconnectedDebounced(peerID)\n            }\n            self.requestPeerDataPublish()\n            self.delegate?.didUpdatePeerList(currentPeerIDs)\n        }\n    }\n    \n    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {\n        let peripheralID = peripheral.identifier.uuidString\n        \n        // Clean up the references\n        peripherals.removeValue(forKey: peripheralID)\n        \n        SecureLogger.error(\"❌ Failed to connect to peripheral: \\(peripheral.name ?? \"Unknown\") [\\(peripheralID)] - Error: \\(error?.localizedDescription ?? \"Unknown\")\", category: .session)\n        failureCounts[peripheralID, default: 0] += 1\n        // Try next candidate\n        bleQueue.async { [weak self] in self?.tryConnectFromQueue() }\n    }\n}\n\n// MARK: - Connection scheduling helpers\nextension BLEService {\n    private func tryConnectFromQueue() {\n        guard let central = centralManager, central.state == .poweredOn else { return }\n        // Check budget and rate limit\n        let current = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count\n        guard current < maxCentralLinks else { return }\n        let delta = Date().timeIntervalSince(lastGlobalConnectAttempt)\n        guard delta >= connectRateLimitInterval else {\n            let delay = connectRateLimitInterval - delta + 0.05\n            bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }\n            return\n        }\n        // Pull best candidate by composite score\n        guard !connectionCandidates.isEmpty else { return }\n        // compute score: connectable> RSSI > recency, with backoff penalty\n        func score(_ c: ConnectionCandidate) -> Int {\n            let uuid = c.peripheral.identifier.uuidString\n            // Penalty if recently timed out (exponential)\n            let fails = failureCounts[uuid] ?? 0\n            let penalty = min(20, (1 << min(4, fails))) // 1,2,4,8,16 cap 16-20\n            let timeoutRecent = recentConnectTimeouts[uuid]\n            let timeoutBias = (timeoutRecent != nil && Date().timeIntervalSince(timeoutRecent!) < 60) ? 10 : 0\n            let base = (c.isConnectable ? 1000 : 0) + (c.rssi + 100) * 2\n            let rec = -Int(Date().timeIntervalSince(c.discoveredAt) * 10)\n            return base + rec - penalty - timeoutBias\n        }\n        connectionCandidates.sort { score($0) > score($1) }\n        let candidate = connectionCandidates.removeFirst()\n        guard candidate.isConnectable else { return }\n        let peripheral = candidate.peripheral\n        let peripheralID = peripheral.identifier.uuidString\n        // Weak-link cooldown: if we recently timed out and RSSI is very weak, delay retries\n        if let lastTO = recentConnectTimeouts[peripheralID] {\n            let elapsed = Date().timeIntervalSince(lastTO)\n            if elapsed < TransportConfig.bleWeakLinkCooldownSeconds && candidate.rssi <= TransportConfig.bleWeakLinkRSSICutoff {\n                // Requeue the candidate and try again later\n                connectionCandidates.append(candidate)\n                let remaining = TransportConfig.bleWeakLinkCooldownSeconds - elapsed\n                let delay = min(max(2.0, remaining), 15.0)\n                bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }\n                return\n            }\n        }\n        if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true {\n            // Already in progress; skip\n            bleQueue.async { [weak self] in self?.tryConnectFromQueue() }\n            return\n        }\n        // Initiate connection\n        peripherals[peripheralID] = PeripheralState(\n            peripheral: peripheral,\n            characteristic: nil,\n            peerID: nil,\n            isConnecting: true,\n            isConnected: false,\n            lastConnectionAttempt: Date(),\n            assembler: NotificationStreamAssembler()\n        )\n        peripheral.delegate = self\n        let options: [String: Any] = [\n            CBConnectPeripheralOptionNotifyOnConnectionKey: true,\n            CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,\n            CBConnectPeripheralOptionNotifyOnNotificationKey: true\n        ]\n        central.connect(peripheral, options: options)\n        lastGlobalConnectAttempt = Date()\n        SecureLogger.debug(\"⏩ Queue connect: \\(candidate.name) [RSSI:\\(candidate.rssi)]\", category: .session)\n    }\n}\n\n#if DEBUG\n// Test-only helper to inject packets into the receive pipeline\nextension BLEService {\n    func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) {\n        if preseedPeer {\n            // Ensure the synthetic peer is known and marked verified for public-message tests\n            let normalizedID = PeerID(hexData: packet.senderID)\n            collectionsQueue.sync(flags: .barrier) {\n                if peers[normalizedID] == nil {\n                    peers[normalizedID] = PeerInfo(\n                        peerID: normalizedID,\n                        nickname: \"TestPeer_\\(fromPeerID.id.prefix(4))\",\n                        isConnected: true,\n                        noisePublicKey: packet.senderID,\n                        signingPublicKey: nil,\n                        isVerifiedNickname: true,\n                        lastSeen: Date()\n                    )\n                } else {\n                    var p = peers[normalizedID]!\n                    p.isConnected = true\n                    p.isVerifiedNickname = true\n                    p.lastSeen = Date()\n                    peers[normalizedID] = p\n                }\n            }\n        }\n        handleReceivedPacket(packet, from: fromPeerID)\n    }\n}\n#endif\n\n// MARK: - CBPeripheralDelegate\n\nextension BLEService: CBPeripheralDelegate {\n    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {\n        if let error = error {\n            SecureLogger.error(\"❌ Error discovering services for \\(peripheral.name ?? \"Unknown\"): \\(error.localizedDescription)\", category: .session)\n            // Retry service discovery after a delay\n            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {\n                guard peripheral.state == .connected else { return }\n                peripheral.discoverServices([BLEService.serviceUUID])\n            }\n            return\n        }\n        \n        guard let services = peripheral.services else {\n            SecureLogger.warning(\"⚠️ No services discovered for \\(peripheral.name ?? \"Unknown\")\", category: .session)\n            return\n        }\n        \n        guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else {\n            // Not a BitChat peer - disconnect\n            centralManager?.cancelPeripheralConnection(peripheral)\n            return\n        }\n        \n        // Discovering BLE characteristics\n        peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service)\n    }\n    \n    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {\n        if let error = error {\n            SecureLogger.error(\"❌ Error discovering characteristics for \\(peripheral.name ?? \"Unknown\"): \\(error.localizedDescription)\", category: .session)\n            return\n        }\n        \n        guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else {\n            SecureLogger.warning(\"⚠️ No matching characteristic found for \\(peripheral.name ?? \"Unknown\")\", category: .session)\n            return\n        }\n        \n        // Found characteristic\n        \n        // Log characteristic properties for debugging\n        var properties: [String] = []\n        if characteristic.properties.contains(.read) { properties.append(\"read\") }\n        if characteristic.properties.contains(.write) { properties.append(\"write\") }\n        if characteristic.properties.contains(.writeWithoutResponse) { properties.append(\"writeWithoutResponse\") }\n        if characteristic.properties.contains(.notify) { properties.append(\"notify\") }\n        if characteristic.properties.contains(.indicate) { properties.append(\"indicate\") }\n        // Characteristic properties: \\(properties.joined(separator: \", \"))\n        \n        // Verify characteristic supports reliable writes\n        if !characteristic.properties.contains(.write) {\n            SecureLogger.warning(\"⚠️ Characteristic doesn't support reliable writes (withResponse)!\", category: .session)\n        }\n        \n        // Store characteristic in our consolidated structure\n        let peripheralID = peripheral.identifier.uuidString\n        if var state = peripherals[peripheralID] {\n            state.characteristic = characteristic\n            peripherals[peripheralID] = state\n        }\n        \n        // Subscribe for notifications\n        if characteristic.properties.contains(.notify) {\n            peripheral.setNotifyValue(true, for: characteristic)\n            SecureLogger.debug(\"🔔 Subscribed to notifications from \\(peripheral.name ?? \"Unknown\")\", category: .session)\n            \n            // Send announce after subscription is confirmed (force send for new connection)\n            messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in\n                self?.sendAnnounce(forceSend: true)\n                // Try flushing any spooled directed packets now that we have a link\n                self?.flushDirectedSpool()\n            }\n        } else {\n            SecureLogger.warning(\"⚠️ Characteristic does not support notifications\", category: .session)\n        }\n    }\n    \n    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {\n        if let error = error {\n            SecureLogger.error(\"❌ Error receiving notification: \\(error.localizedDescription)\", category: .session)\n            return\n        }\n        \n        guard let data = characteristic.value, !data.isEmpty else {\n            SecureLogger.warning(\"⚠️ No data in notification\", category: .session)\n            return\n        }\n\n        bufferNotificationChunk(data, from: peripheral)\n    }\n\n    private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {\n        let peripheralUUID = peripheral.identifier.uuidString\n\n        var state = peripherals[peripheralUUID] ?? PeripheralState(\n            peripheral: peripheral,\n            characteristic: nil,\n            peerID: nil,\n            isConnecting: false,\n            isConnected: peripheral.state == .connected,\n            lastConnectionAttempt: nil,\n            assembler: NotificationStreamAssembler()\n        )\n\n        var assembler = state.assembler\n        let result = assembler.append(chunk)\n        state.assembler = assembler\n        peripherals[peripheralUUID] = state\n\n        for byte in result.droppedPrefixes {\n            SecureLogger.warning(\"⚠️ Dropping byte from BLE stream (unexpected prefix \\(String(format: \"%02x\", byte)))\", category: .session)\n        }\n\n        if result.reset {\n            SecureLogger.error(\"❌ Invalid BLE frame length; reset notification stream\", category: .session)\n        }\n        \n        // Codex review identified TOCTOU in this patch.\n        // Enforce per-link sender binding immediately within the same notification batch.\n        // NOTE: `processNotificationPacket` may bind `peripherals[peripheralUUID].peerID` when an announce\n        // is processed, but `state` above is a snapshot. Track a local binding that we update as soon as\n        // we see a binding-eligible announce so subsequent frames can't spoof a different sender.\n        var boundPeerID: PeerID? = state.peerID\n\n        for frame in result.frames {\n            guard let packet = BinaryProtocol.decode(frame) else {\n                let prefix = frame.prefix(16).map { String(format: \"%02x\", $0) }.joined(separator: \" \")\n                SecureLogger.error(\"❌ Failed to decode assembled notification frame (len=\\(frame.count), prefix=\\(prefix))\", category: .session)\n                continue\n            }\n\n            let claimedSenderID = PeerID(hexData: packet.senderID)\n\n            let trustedSenderID: PeerID?\n            if let knownPeerID = boundPeerID {\n                if knownPeerID != claimedSenderID {\n                    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)\n                    continue\n                }\n                trustedSenderID = knownPeerID\n            } else {\n                trustedSenderID = nil\n            }\n\n            if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .peripheral(peripheralUUID)) {\n                continue\n            }\n\n            // If this is a direct-link announce, bind immediately for the remainder of this batch.\n            if boundPeerID == nil,\n               packet.type == MessageType.announce.rawValue,\n               packet.ttl == messageTTL {\n                boundPeerID = claimedSenderID\n                state.peerID = claimedSenderID\n                peripherals[peripheralUUID] = state\n            }\n            processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID)\n        }\n    }\n\n    private func processNotificationPacket(_ packet: BitchatPacket, from peripheral: CBPeripheral, peripheralUUID: String) {\n        let senderID = PeerID(hexData: packet.senderID)\n\n        if packet.type != MessageType.announce.rawValue {\n            SecureLogger.debug(\"📦 Decoded notification packet type: \\(packet.type) from sender: \\(senderID)\", category: .session)\n        }\n\n        if packet.type == MessageType.announce.rawValue {\n            if packet.ttl == messageTTL {\n                if var state = peripherals[peripheralUUID] {\n                    state.peerID = senderID\n                    peripherals[peripheralUUID] = state\n                }\n                peerToPeripheralUUID[senderID] = peripheralUUID\n                refreshLocalTopology()\n            }\n\n            let msgID = makeMessageID(for: packet)\n            collectionsQueue.async(flags: .barrier) { [weak self] in\n                self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())\n            }\n            handleReceivedPacket(packet, from: senderID)\n        } else {\n            let msgID = makeMessageID(for: packet)\n            collectionsQueue.async(flags: .barrier) { [weak self] in\n                self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())\n            }\n            handleReceivedPacket(packet, from: senderID)\n        }\n    }\n    \n    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {\n        if let error = error {\n            SecureLogger.error(\"❌ Write failed to \\(peripheral.name ?? peripheral.identifier.uuidString): \\(error.localizedDescription)\", category: .session)\n            // Don't retry - just log the error\n        } else {\n            SecureLogger.debug(\"✅ Write confirmed to \\(peripheral.name ?? peripheral.identifier.uuidString)\", category: .session)\n        }\n    }\n    \n    func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {\n        // Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again\n        SecureLogger.debug(\"📤 Peripheral \\(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes\", category: .session)\n        drainPendingWrites(for: peripheral)\n    }\n    \n    func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {\n        SecureLogger.warning(\"⚠️ Services modified for \\(peripheral.name ?? peripheral.identifier.uuidString)\", category: .session)\n        \n        // Check if our service was invalidated (peer app quit)\n        let hasOurService = peripheral.services?.contains { $0.uuid == BLEService.serviceUUID } ?? false\n        \n        if !hasOurService {\n            // Service is gone - disconnect\n            SecureLogger.warning(\"❌ BitChat service removed - disconnecting from \\(peripheral.name ?? peripheral.identifier.uuidString)\", category: .session)\n            centralManager?.cancelPeripheralConnection(peripheral)\n        } else {\n            // Try to rediscover\n            peripheral.discoverServices([BLEService.serviceUUID])\n        }\n    }\n    \n    func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {\n        if let error = error {\n            SecureLogger.error(\"❌ Error updating notification state: \\(error.localizedDescription)\", category: .session)\n        } else {\n            SecureLogger.debug(\"🔔 Notification state updated for \\(peripheral.name ?? peripheral.identifier.uuidString): \\(characteristic.isNotifying ? \"ON\" : \"OFF\")\", category: .session)\n            \n            // If notifications are now on, send an announce to ensure this peer knows about us\n            if characteristic.isNotifying {\n                // Sending announce after subscription\n                self.sendAnnounce(forceSend: true)\n            }\n        }\n    }\n\n}\n\n// MARK: - CBPeripheralManagerDelegate\n\nextension BLEService: CBPeripheralManagerDelegate {\n    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {\n        SecureLogger.debug(\"📡 Peripheral manager state: \\(peripheral.state.rawValue)\", category: .session)\n\n        switch peripheral.state {\n        case .poweredOn:\n            // Remove all services first to ensure clean state\n            peripheral.removeAllServices()\n\n            // Create characteristic\n            characteristic = CBMutableCharacteristic(\n                type: BLEService.characteristicUUID,\n                properties: [.notify, .write, .writeWithoutResponse, .read],\n                value: nil,\n                permissions: [.readable, .writeable]\n            )\n\n            // Create service\n            let service = CBMutableService(type: BLEService.serviceUUID, primary: true)\n            service.characteristics = [characteristic!]\n\n            // Add service (advertising will start in didAdd delegate)\n            SecureLogger.debug(\"🔧 Adding BLE service...\", category: .session)\n            peripheral.add(service)\n\n        case .poweredOff:\n            // Bluetooth was turned off - clean up peripheral state\n            SecureLogger.info(\"📴 Bluetooth powered off - cleaning up peripheral state\", category: .session)\n            peripheral.stopAdvertising()\n            // Clear subscribed centrals (they are now invalid)\n            let centralPeerIDs = centralToPeerID.values.map { $0 }\n            subscribedCentrals.removeAll()\n            centralToPeerID.removeAll()\n            centralSubscriptionRateLimits.removeAll()\n            characteristic = nil\n            // Notify UI of disconnections\n            for peerID in centralPeerIDs {\n                notifyUI { [weak self] in\n                    self?.notifyPeerDisconnectedDebounced(peerID)\n                }\n            }\n\n        case .unauthorized:\n            // User denied Bluetooth permission\n            SecureLogger.warning(\"🚫 Bluetooth unauthorized for peripheral role\", category: .session)\n            peripheral.stopAdvertising()\n            subscribedCentrals.removeAll()\n            centralToPeerID.removeAll()\n            centralSubscriptionRateLimits.removeAll()\n            characteristic = nil\n\n        case .unsupported:\n            // Device doesn't support BLE peripheral role\n            SecureLogger.error(\"❌ Bluetooth LE peripheral role not supported\", category: .session)\n\n        case .resetting:\n            // Bluetooth stack is resetting\n            SecureLogger.info(\"🔄 Bluetooth peripheral stack resetting...\", category: .session)\n\n        case .unknown:\n            SecureLogger.debug(\"❓ Peripheral Bluetooth state unknown (initializing)\", category: .session)\n\n        @unknown default:\n            SecureLogger.warning(\"⚠️ Unknown peripheral Bluetooth state: \\(peripheral.state.rawValue)\", category: .session)\n        }\n    }\n    \n    #if os(iOS)\n    func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) {\n        let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []\n        let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]\n\n        SecureLogger.info(\n            \"♻️ Peripheral restore: services=\\(restoredServices.count) advertisingDataKeys=\\(Array(restoredAdvertisement.keys))\",\n            category: .session\n        )\n\n        // Attempt to recover characteristic from restored services\n        if characteristic == nil {\n            if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }),\n               let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic {\n                characteristic = restoredCharacteristic\n            }\n        }\n\n        captureBluetoothStatus(context: \"peripheral-restore\")\n\n        if peripheral.state == .poweredOn && !peripheral.isAdvertising {\n            peripheral.startAdvertising(buildAdvertisementData())\n        }\n    }\n    #endif\n    \n    func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {\n        if let error = error {\n            SecureLogger.error(\"❌ Failed to add service: \\(error.localizedDescription)\", category: .session)\n            return\n        }\n        \n        SecureLogger.debug(\"✅ Service added successfully, starting advertising\", category: .session)\n        \n        // Start advertising after service is confirmed added\n        let adData = buildAdvertisementData()\n        peripheral.startAdvertising(adData)\n        \n        SecureLogger.debug(\"📡 Started advertising (LocalName: \\((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? \"on\" : \"off\"), ID: \\(myPeerID.id.prefix(8))…)\", category: .session)\n    }\n    \n    func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {\n        let centralUUID = central.identifier.uuidString\n        SecureLogger.debug(\"📥 Central subscribed: \\(centralUUID)\", category: .session)\n        subscribedCentrals.append(central)\n\n        // BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks\n        let now = Date()\n        var state = centralSubscriptionRateLimits[centralUUID]\n\n        // Clean up stale entries periodically\n        cleanupStaleSubscriptionRateLimits()\n\n        // Check if this central is rate-limited\n        if let existingState = state {\n            let timeSinceLastAnnounce = now.timeIntervalSince(existingState.lastAnnounceTime)\n\n            // If within backoff period, skip the announce\n            if timeSinceLastAnnounce < existingState.currentBackoffSeconds {\n                SecureLogger.warning(\"🛡️ BCH-01-004: Rate-limited announce for central \\(centralUUID.prefix(8))... (backoff: \\(Int(existingState.currentBackoffSeconds))s, attempts: \\(existingState.attemptCount))\", category: .security)\n\n                // Increment attempt count and increase backoff\n                // Update lastAnnounceTime to 'now' so each blocked attempt extends the suppression window\n                // This prevents attackers from waiting out the backoff while spamming attempts\n                let newAttemptCount = existingState.attemptCount + 1\n                let newBackoff = min(\n                    existingState.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor,\n                    TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds\n                )\n                centralSubscriptionRateLimits[centralUUID] = SubscriptionRateLimitState(\n                    lastAnnounceTime: now,  // Reset timer on each blocked attempt\n                    attemptCount: newAttemptCount,\n                    currentBackoffSeconds: newBackoff\n                )\n\n                // If too many rapid attempts, this is likely an enumeration attack - don't respond\n                if newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts {\n                    SecureLogger.warning(\"🚨 BCH-01-004: Possible enumeration attack from central \\(centralUUID.prefix(8))... - suppressing announce\", category: .security)\n                    return\n                }\n\n                // Still flush directed packets for legitimate mesh operation\n                messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in\n                    self?.flushDirectedSpool()\n                }\n                return\n            }\n\n            // Outside backoff period - allow announce but track it\n            state = SubscriptionRateLimitState(\n                lastAnnounceTime: now,\n                attemptCount: 1,\n                currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds\n            )\n        } else {\n            // First subscription from this central - track it\n            state = SubscriptionRateLimitState(\n                lastAnnounceTime: now,\n                attemptCount: 1,\n                currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds\n            )\n        }\n        centralSubscriptionRateLimits[centralUUID] = state\n\n        // Send announce to the newly subscribed central after a small delay\n        messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in\n            self?.sendAnnounce(forceSend: true)\n            // Flush any spooled directed packets now that we have a central subscribed\n            self?.flushDirectedSpool()\n        }\n    }\n\n    /// BCH-01-004: Clean up stale rate-limit entries to prevent memory growth\n    private func cleanupStaleSubscriptionRateLimits() {\n        let now = Date()\n        let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds\n        centralSubscriptionRateLimits = centralSubscriptionRateLimits.filter { _, state in\n            now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds\n        }\n    }\n    \n    func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {\n        SecureLogger.debug(\"📤 Central unsubscribed: \\(central.identifier.uuidString)\", category: .session)\n        subscribedCentrals.removeAll { $0.identifier == central.identifier }\n        \n        // Ensure we're still advertising for other devices to find us\n        if peripheral.isAdvertising == false {\n            SecureLogger.debug(\"📡 Restarting advertising after central unsubscribed\", category: .session)\n            peripheral.startAdvertising(buildAdvertisementData())\n        }\n        \n        // Find and disconnect the peer associated with this central\n        let centralUUID = central.identifier.uuidString\n        if let peerID = centralToPeerID[centralUUID] {\n            // Mark peer as not connected; retain for reachability\n            collectionsQueue.sync(flags: .barrier) {\n                if var info = peers[peerID] {\n                    info.isConnected = false\n                    peers[peerID] = info\n                }\n            }\n            \n            // Clean up mappings\n            centralToPeerID.removeValue(forKey: centralUUID)\n            refreshLocalTopology()\n            \n            // Update UI immediately\n            notifyUI { [weak self] in\n                guard let self = self else { return }\n                \n                // Get current peer list (after removal)\n                let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }\n                \n                self.notifyPeerDisconnectedDebounced(peerID)\n                // Publish snapshots so UnifiedPeerService can refresh icons promptly\n                self.requestPeerDataPublish()\n                self.delegate?.didUpdatePeerList(currentPeerIDs)\n            }\n        }\n    }\n    \n    func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {\n        SecureLogger.debug(\"📤 Peripheral manager ready to send more notifications\", category: .session)\n        \n        // Retry pending notifications now that queue has space\n        collectionsQueue.async(flags: .barrier) { [weak self] in\n            guard let self = self,\n                  let characteristic = self.characteristic,\n                  !self.pendingNotifications.isEmpty else { return }\n            \n            let pending = self.pendingNotifications\n            self.pendingNotifications.removeAll()\n            \n            // Try to send pending notifications\n            var sentCount = 0\n            for (index, (data, centrals)) in pending.enumerated() {\n                if let centrals = centrals {\n                    // Send to specific centrals\n                    let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false\n                    if !success {\n                        // Still full, re-queue this and all remaining items\n                        let remaining = pending.dropFirst(index)\n                        self.pendingNotifications.append(contentsOf: remaining)\n                        SecureLogger.debug(\"⚠️ Notification queue still full after \\(sentCount) sent, re-queuing \\(remaining.count) items\", category: .session)\n                        break  // Stop trying, wait for next ready callback\n                    } else {\n                        sentCount += 1\n                    }\n                } else {\n                    // Broadcast to all\n                    let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false\n                    if !success {\n                        // Still full, re-queue this and all remaining items\n                        let remaining = pending.dropFirst(index)\n                        self.pendingNotifications.append(contentsOf: remaining)\n                        SecureLogger.debug(\"⚠️ Notification queue still full after \\(sentCount) sent, re-queuing \\(remaining.count) items\", category: .session)\n                        break\n                    } else {\n                        sentCount += 1\n                    }\n                }\n            }\n\n            if sentCount > 0 {\n                SecureLogger.debug(\"✅ Sent \\(sentCount) pending notifications from retry queue\", category: .session)\n            }\n            \n            if !self.pendingNotifications.isEmpty {\n                SecureLogger.debug(\"📋 Still have \\(self.pendingNotifications.count) pending notifications\", category: .session)\n            }\n        }\n    }\n    \n    func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {\n        // Suppress logs for single write requests to reduce noise\n        if requests.count > 1 {\n            SecureLogger.debug(\"📥 Received \\(requests.count) write requests from central\", category: .session)\n        }\n        \n        // IMPORTANT: Respond immediately to prevent timeouts!\n        // We must respond within a few milliseconds or the central will timeout\n        for request in requests {\n            peripheral.respond(to: request, withResult: .success)\n        }\n        \n        // Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.\n        // Combine per-central request values by offset before decoding.\n        // Process directly on our message queue to match transport context\n        let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString })\n        for (centralUUID, group) in grouped {\n            // Sort by offset ascending\n            let sorted = group.sorted { $0.offset < $1.offset }\n            let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0\n\n            // Always merge into a persistent per-central buffer to handle multi-callback long writes\n            var combined = pendingWriteBuffers[centralUUID] ?? Data()\n            var appendedBytes = 0\n            var offsets: [Int] = []\n            for r in sorted {\n                guard let chunk = r.value, !chunk.isEmpty else { continue }\n                offsets.append(r.offset)\n                let end = r.offset + chunk.count\n                if combined.count < end {\n                    combined.append(Data(repeating: 0, count: end - combined.count))\n                }\n                // Write chunk into the correct position (supports out-of-order and overlapping writes)\n                combined.replaceSubrange(r.offset..<end, with: chunk)\n                appendedBytes += chunk.count\n            }\n            pendingWriteBuffers[centralUUID] = combined\n\n            // Peek type byte for debug: version is at 0, type at 1 when well-formed\n            if combined.count >= 2 {\n                let peekType = combined[1]\n                if peekType != MessageType.announce.rawValue {\n                    SecureLogger.debug(\"📥 Accumulated write from central \\(centralUUID): size=\\(combined.count) (+\\(appendedBytes)) bytes (type=\\(peekType)), offsets=\\(offsets)\", category: .session)\n                }\n            }\n\n            // Try decode the accumulated buffer\n            if let packet = BinaryProtocol.decode(combined) {\n                // Clear buffer on success\n                pendingWriteBuffers.removeValue(forKey: centralUUID)\n\n                let claimedSenderID = PeerID(hexData: packet.senderID)\n\n                let trustedSenderID: PeerID?\n                if let knownPeerID = centralToPeerID[centralUUID] {\n                    if knownPeerID != claimedSenderID {\n                        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)\n                        continue\n                    }\n                    trustedSenderID = knownPeerID\n                } else {\n                    trustedSenderID = nil\n                }\n\n                if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .central(centralUUID)) {\n                    continue\n                }\n\n                if packet.type != MessageType.announce.rawValue {\n                    SecureLogger.debug(\"📦 Decoded (combined) packet type: \\(packet.type) from sender: \\(claimedSenderID)\", category: .session)\n                }\n                if !subscribedCentrals.contains(sorted[0].central) {\n                    subscribedCentrals.append(sorted[0].central)\n                }\n                if packet.type == MessageType.announce.rawValue {\n                    if packet.ttl == messageTTL {\n                        centralToPeerID[centralUUID] = claimedSenderID\n                        refreshLocalTopology()\n                    }\n                    // Record ingress link for last-hop suppression then process\n                    let msgID = makeMessageID(for: packet)\n                    collectionsQueue.async(flags: .barrier) { [weak self] in\n                        self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())\n                    }\n                    handleReceivedPacket(packet, from: claimedSenderID)\n                } else {\n                    // Record ingress link for last-hop suppression then process\n                    let msgID = makeMessageID(for: packet)\n                    collectionsQueue.async(flags: .barrier) { [weak self] in\n                        self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())\n                    }\n                    handleReceivedPacket(packet, from: claimedSenderID)\n                }\n            } else {\n                // If buffer grows suspiciously large, reset to avoid memory leak\n                if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety\n                    pendingWriteBuffers.removeValue(forKey: centralUUID)\n                    SecureLogger.warning(\"⚠️ Dropping oversized pending write buffer (\\(combined.count) bytes) for central \\(centralUUID)\", category: .session)\n                }\n                // If this was a single short write and still failed, log the raw chunk for debugging\n                if !hasMultiple, let only = sorted.first, let raw = only.value {\n                    let prefix = raw.prefix(16).map { String(format: \"%02x\", $0) }.joined(separator: \" \")\n                    SecureLogger.error(\"❌ Failed to decode packet from central (len=\\(raw.count), prefix=\\(prefix))\", category: .session)\n                }\n            }\n        }\n    }    \n}\n\n// MARK: - Advertising Builders & Alias Rotation\n\nextension BLEService {\n    private func buildAdvertisementData() -> [String: Any] {\n        let data: [String: Any] = [\n            CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID]\n        ]\n        // No Local Name for privacy\n        return data\n    }\n    \n    // No alias rotation or advertising restarts required.\n}\n\n// MARK: - Private Helpers\n\nextension BLEService {\n    \n    /// Notify UI on the MainActor to satisfy Swift concurrency isolation\n    private func notifyUI(_ block: @escaping () -> Void) {\n        // Always hop onto the MainActor so calls to @MainActor delegates are safe\n        Task { @MainActor in\n            block()\n        }\n    }\n\n    private func logBluetoothStatus(_ context: String) {\n        bleQueue.async { [weak self] in\n            guard let self = self else { return }\n            self.captureBluetoothStatus(context: context)\n        }\n    }\n\n    private func scheduleBluetoothStatusSample(after delay: TimeInterval, context: String) {\n        bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in\n            guard let self = self else { return }\n            self.captureBluetoothStatus(context: context)\n        }\n    }\n\n    private func captureBluetoothStatus(context: String) {\n        assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, \"captureBluetoothStatus must run on bleQueue\")\n\n        let centralState = centralManager?.state ?? .unknown\n        let isScanning = centralManager?.isScanning ?? false\n        let peripheralState = peripheralManager?.state ?? .unknown\n        let isAdvertising = peripheralManager?.isAdvertising ?? false\n\n        let peerSummary = collectionsQueue.sync {\n            (\n                connected: peers.values.filter { $0.isConnected }.count,\n                known: peers.count,\n                candidates: connectionCandidates.count\n            )\n        }\n\n        #if os(iOS)\n        var backgroundDescriptor = \"\"\n        var backgroundSeconds: TimeInterval = 0\n        DispatchQueue.main.sync {\n            backgroundSeconds = UIApplication.shared.backgroundTimeRemaining\n        }\n        if backgroundSeconds == .greatestFiniteMagnitude {\n            backgroundDescriptor = \" bgRemaining=∞\"\n        } else {\n            backgroundDescriptor = String(format: \" bgRemaining=%.1fs\", backgroundSeconds)\n        }\n        let appPhase = isAppActive ? \"foreground\" : \"background\"\n        #else\n        let backgroundDescriptor = \"\"\n        let appPhase = \"foreground\"\n        #endif\n\n        SecureLogger.info(\n            \"📊 BLE status [\\(context)]: phase=\\(appPhase) central=\\(centralState) scanning=\\(isScanning) peripheral=\\(peripheralState) advertising=\\(isAdvertising) connected=\\(peerSummary.connected) known=\\(peerSummary.known) candidates=\\(peerSummary.candidates)\\(backgroundDescriptor)\",\n            category: .session\n        )\n    }\n\n    private func routingData(for peerID: PeerID) -> Data? {\n        peerID.toShort().routingData\n    }\n\n    private func refreshLocalTopology() {\n        let neighbors: [Data] = collectionsQueue.sync {\n            peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData }\n        }\n        meshTopology.updateNeighbors(for: myPeerIDData, neighbors: neighbors)\n    }\n\n    private func computeRoute(to peerID: PeerID) -> [Data]? {\n        meshTopology.computeRoute(from: myPeerIDData, to: routingData(for: peerID))\n    }\n\n    private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {\n        guard let route = computeRoute(to: recipient), route.count >= 1 else {\n            return packet\n        }\n        // Create new packet with route applied and version upgraded to 2\n        let routedPacket = BitchatPacket(\n            type: packet.type,\n            senderID: packet.senderID,\n            recipientID: packet.recipientID,\n            timestamp: packet.timestamp,\n            payload: packet.payload,\n            signature: nil, // Will be re-signed below\n            ttl: packet.ttl,\n            version: 2,\n            route: route\n        )\n        // Re-sign the packet since route and version changed\n        guard let signedPacket = noiseService.signPacket(routedPacket) else {\n            SecureLogger.error(\"❌ Failed to re-sign packet with route\", category: .security)\n            return packet // Return original packet if signing fails\n        }\n        return signedPacket\n    }\n\n    private func routingPeer(from data: Data) -> PeerID? {\n        PeerID(routingData: data)\n    }\n\n    private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {\n        guard let route = packet.route, !route.isEmpty else { return false }\n        let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)\n        guard let selfData = myRoutingData else { return false }\n        \n        // Route contains only intermediate hops (start and end excluded)\n        // If we're not in the route, we're the sender - forward to first hop\n        guard let index = route.firstIndex(of: selfData) else {\n            // We're the sender, forward to first intermediate hop\n            guard packet.ttl > 1 else { return true }\n            let firstHopData = route[0]\n            guard let nextPeer = routingPeer(from: firstHopData),\n                  isPeerConnected(nextPeer) else {\n                return false\n            }\n            var relayPacket = packet\n            relayPacket.ttl = packet.ttl - 1\n            sendPacketDirected(relayPacket, to: nextPeer)\n            return true\n        }\n\n        // We're an intermediate node in the route\n        // If we're the last intermediate hop, forward to destination\n        if index == route.count - 1 {\n            guard packet.ttl > 1 else { return true }\n            guard let destinationPeer = PeerID(hexData: packet.recipientID),\n                  isPeerConnected(destinationPeer) else {\n                return false\n            }\n            var relayPacket = packet\n            relayPacket.ttl = packet.ttl - 1\n            sendPacketDirected(relayPacket, to: destinationPeer)\n            return true\n        }\n\n        // Forward to next intermediate hop\n        guard packet.ttl > 1 else { return true }\n        let nextHopData = route[index + 1]\n        guard let nextPeer = routingPeer(from: nextHopData),\n              isPeerConnected(nextPeer) else {\n            return false\n        }\n\n        var relayPacket = packet\n        relayPacket.ttl = packet.ttl - 1\n        sendPacketDirected(relayPacket, to: nextPeer)\n        return true\n    }\n\n    /// Safely fetch the current direct-link state for a peer using the BLE queue.\n    private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) {\n        let computeState = { () -> (Bool, Bool) in\n            let peripheralUUID = self.peerToPeripheralUUID[peerID]\n            let hasPeripheral = peripheralUUID.flatMap { self.peripherals[$0]?.isConnected } ?? false\n            let hasCentral = self.centralToPeerID.values.contains(peerID)\n            return (hasPeripheral, hasCentral)\n        }\n\n        if DispatchQueue.getSpecific(key: bleQueueKey) != nil {\n            return computeState()\n        } else {\n            return bleQueue.sync { computeState() }\n        }\n    }\n    \n    private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {\n        service.onPeerAuthenticated = { [weak self] peerID, fingerprint in\n            SecureLogger.debug(\"🔐 Noise session authenticated with \\(peerID), fingerprint: \\(fingerprint.prefix(16))...\")\n            self?.messageQueue.async { [weak self] in\n                self?.sendPendingMessagesAfterHandshake(for: peerID)\n                self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)\n            }\n            self?.messageQueue.async { [weak self] in\n                self?.sendAnnounce(forceSend: true)\n            }\n        }\n    }\n\n    private func refreshPeerIdentity() {\n        let fingerprint = noiseService.getIdentityFingerprint()\n        myPeerID = PeerID(str: fingerprint.prefix(16))\n        myPeerIDData = Data(hexString: myPeerID.id) ?? Data()\n        meshTopology.reset()\n    }\n\n\n    \n    private func sendNoisePayload(_ typedPayload: Data, to peerID: PeerID) {\n        guard noiseService.hasSession(with: peerID) else {\n            // No session yet - queue the payload SYNCHRONOUSLY before initiating handshake\n            // to prevent race where fast handshake completion drains empty queue\n            collectionsQueue.sync(flags: .barrier) {\n                if self.pendingNoisePayloadsAfterHandshake[peerID] == nil {\n                    self.pendingNoisePayloadsAfterHandshake[peerID] = []\n                }\n                self.pendingNoisePayloadsAfterHandshake[peerID]?.append(typedPayload)\n                SecureLogger.debug(\"📥 Queued noise payload for \\(peerID) pending handshake\", category: .session)\n            }\n            initiateNoiseHandshake(with: peerID)\n            return\n        }\n        do {\n            let encrypted = try noiseService.encrypt(typedPayload, for: peerID)\n            let packet = BitchatPacket(\n                type: MessageType.noiseEncrypted.rawValue,\n                senderID: myPeerIDData,\n                recipientID: Data(hexString: peerID.id),\n                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                payload: encrypted,\n                signature: nil,\n                ttl: messageTTL\n            )\n            broadcastPacket(packet)\n        } catch {\n            SecureLogger.error(\"Failed to send verification payload: \\(error)\")\n        }\n    }\n    \n    // MARK: Link capability snapshots (thread-safe via bleQueue)\n    \n    private func snapshotPeripheralStates() -> [PeripheralState] {\n        if DispatchQueue.getSpecific(key: bleQueueKey) != nil {\n            return Array(peripherals.values)\n        } else {\n            return bleQueue.sync { Array(peripherals.values) }\n        }\n    }\n    private func snapshotSubscribedCentrals() -> ([CBCentral], [String: PeerID]) {\n        if DispatchQueue.getSpecific(key: bleQueueKey) != nil {\n            return (self.subscribedCentrals, self.centralToPeerID)\n        } else {\n            return bleQueue.sync { (self.subscribedCentrals, self.centralToPeerID) }\n        }\n    }\n    \n    // MARK: Helpers: IDs, selection, and write backpressure\n    \n    private func makeMessageID(for packet: BitchatPacket) -> String {\n        let senderID = packet.senderID.hexEncodedString()\n        let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()\n        return \"\\(senderID)-\\(packet.timestamp)-\\(packet.type)-\\(digestPrefix)\"\n    }\n\n    private func subsetSizeForFanout(_ n: Int) -> Int {\n        guard n > 0 else { return 0 }\n        if n <= 2 { return n }\n        // approx ceil(log2(n)) + 1 without floating point\n        var v = n - 1\n        var bits = 0\n        while v > 0 { v >>= 1; bits += 1 }\n        return min(n, max(1, bits + 1))\n    }\n\n    private func selectDeterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {\n        guard k > 0 && ids.count > k else { return Set(ids) }\n        // Stable order by SHA256(seed || \"::\" || id)\n        var scored: [(score: [UInt8], id: String)] = []\n        for id in ids {\n            let msg = (seed + \"::\" + id).data(using: .utf8) ?? Data()\n            let digest = Array(SHA256.hash(data: msg))\n            scored.append((digest, id))\n        }\n        scored.sort { a, b in\n            for i in 0..<min(a.score.count, b.score.count) {\n                if a.score[i] != b.score[i] { return a.score[i] < b.score[i] }\n            }\n            return a.id < b.id\n        }\n        return Set(scored.prefix(k).map { $0.id })\n    }\n\n    private func priority(for packet: BitchatPacket, data: Data) -> OutboundPriority {\n        guard let messageType = MessageType(rawValue: packet.type) else { return .low }\n        switch messageType {\n        case .fragment:\n            let total = fragmentTotalCount(from: packet.payload)\n            return OutboundPriority.fragment(totalFragments: total)\n        case .fileTransfer:\n            return .fileTransfer\n        default:\n            return .high\n        }\n    }\n\n    private func fragmentTotalCount(from payload: Data) -> Int {\n        guard payload.count >= 12 else { return Int(UInt16.max) }\n        let totalHigh = Int(payload[10])\n        let totalLow = Int(payload[11])\n        let total = (totalHigh << 8) | totalLow\n        return max(total, 1)\n    }\n\n    private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic, priority: OutboundPriority) {\n        // BLE operations run on bleQueue; keep queue affinity\n        bleQueue.async { [weak self] in\n            guard let self = self else { return }\n            let uuid = peripheral.identifier.uuidString\n            if peripheral.canSendWriteWithoutResponse {\n                peripheral.writeValue(data, for: characteristic, type: .withoutResponse)\n            } else {\n                self.collectionsQueue.async(flags: .barrier) {\n                    var queue = self.pendingPeripheralWrites[uuid] ?? []\n                    let capBytes = TransportConfig.blePendingWriteBufferCapBytes\n                    let newSize = data.count\n                    // If single chunk exceeds cap, drop it immediately\n                    if newSize > capBytes {\n                        SecureLogger.warning(\"⚠️ Dropping oversized write chunk (\\(newSize)B) for peripheral \\(uuid)\", category: .session)\n                    } else {\n                        let item = PendingWrite(priority: priority, data: data)\n                        var total = queue.reduce(0) { $0 + $1.data.count } + newSize\n                        let insertIndex = queue.firstIndex { item.priority < $0.priority } ?? queue.count\n                        queue.insert(item, at: insertIndex)\n                        if total > capBytes {\n                            var removedBytes = 0\n                            while total > capBytes && !queue.isEmpty {\n                                let removed = queue.removeLast()\n                                removedBytes += removed.data.count\n                                total -= removed.data.count\n                            }\n                            if removedBytes > 0 {\n                                SecureLogger.warning(\"📉 Trimmed pending write buffer for \\(uuid) by \\(removedBytes)B to \\(total)B\", category: .session)\n                            }\n                        }\n                        self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue\n                    }\n                }\n            }\n        }\n    }\n\n    private func drainPendingWrites(for peripheral: CBPeripheral) {\n        let uuid = peripheral.identifier.uuidString\n        bleQueue.async { [weak self] in\n            guard let self = self else { return }\n            guard let state = self.peripherals[uuid], let ch = state.characteristic else { return }\n\n            // Atomically take all pending items from the queue to avoid race conditions\n            // where new items could be enqueued between read and update\n            let itemsToSend: [PendingWrite] = self.collectionsQueue.sync(flags: .barrier) {\n                let items = self.pendingPeripheralWrites[uuid] ?? []\n                self.pendingPeripheralWrites[uuid] = nil\n                return items\n            }\n            guard !itemsToSend.isEmpty else { return }\n\n            // Send as many as possible\n            var sent = 0\n            for item in itemsToSend {\n                if peripheral.canSendWriteWithoutResponse {\n                    peripheral.writeValue(item.data, for: ch, type: .withoutResponse)\n                    sent += 1\n                } else {\n                    break\n                }\n            }\n\n            // Re-enqueue any items that couldn't be sent (maintaining order)\n            let unsent = Array(itemsToSend.dropFirst(sent))\n            if !unsent.isEmpty {\n                self.collectionsQueue.async(flags: .barrier) {\n                    var existing = self.pendingPeripheralWrites[uuid] ?? []\n                    // Prepend unsent items to maintain priority order\n                    existing.insert(contentsOf: unsent, at: 0)\n                    self.pendingPeripheralWrites[uuid] = existing\n                }\n            }\n        }\n    }\n\n    /// Periodically try to drain pending notifications as a backup mechanism\n    private func drainPendingNotificationsIfPossible() {\n        collectionsQueue.async(flags: .barrier) { [weak self] in\n            guard let self = self,\n                  let characteristic = self.characteristic,\n                  !self.pendingNotifications.isEmpty else { return }\n\n            let pending = self.pendingNotifications\n            self.pendingNotifications.removeAll()\n\n            var sentCount = 0\n            for (index, (data, centrals)) in pending.enumerated() {\n                let success: Bool\n                if let centrals = centrals {\n                    success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false\n                } else {\n                    success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false\n                }\n\n                if !success {\n                    // Re-queue this and all remaining items\n                    let remaining = pending.dropFirst(index)\n                    self.pendingNotifications.append(contentsOf: remaining)\n                    break\n                } else {\n                    sentCount += 1\n                }\n            }\n\n            if sentCount > 0 {\n                SecureLogger.debug(\"🔄 Periodic drain: sent \\(sentCount) pending notifications\", category: .session)\n            }\n        }\n    }\n\n    /// Periodically try to drain pending writes for all connected peripherals\n    private func drainAllPendingWrites() {\n        let uuids = collectionsQueue.sync { Array(pendingPeripheralWrites.keys) }\n        for uuid in uuids {\n            guard let state = peripherals[uuid], state.isConnected else { continue }\n            drainPendingWrites(for: state.peripheral)\n        }\n    }\n\n    // MARK: Application State Handlers (iOS)\n\n    #if os(iOS)\n    @objc private func appDidBecomeActive() {\n        isAppActive = true\n        // Restart scanning with allow duplicates when app becomes active\n        if centralManager?.state == .poweredOn {\n            centralManager?.stopScan()\n            startScanning()\n        }\n        logBluetoothStatus(\"became-active\")\n        scheduleBluetoothStatusSample(after: 5.0, context: \"active-5s\")\n        // No Local Name; nothing to refresh for advertising policy\n    }\n    \n    @objc private func appDidEnterBackground() {\n        isAppActive = false\n        // Restart scanning without allow duplicates in background\n        if centralManager?.state == .poweredOn {\n            centralManager?.stopScan()\n            startScanning()\n        }\n        logBluetoothStatus(\"entered-background\")\n        scheduleBluetoothStatusSample(after: 15.0, context: \"background-15s\")\n        // No Local Name; nothing to refresh for advertising policy\n    }\n    #endif\n    \n    // MARK: Private Message Handling\n    \n    private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) {\n        SecureLogger.debug(\"📨 Sending PM to \\(recipientID): \\(content.prefix(30))...\", category: .session)\n        \n        // Check if we have an established Noise session\n        if noiseService.hasEstablishedSession(with: recipientID) {\n            // Encrypt and send\n            do {\n                // Create TLV-encoded private message\n                let privateMessage = PrivateMessagePacket(messageID: messageID, content: content)\n                guard let tlvData = privateMessage.encode() else {\n                    SecureLogger.error(\"Failed to encode private message with TLV\")\n                    return\n                }\n                \n                // Create message payload with TLV: [type byte] + [TLV data]\n                var messagePayload = Data([NoisePayloadType.privateMessage.rawValue])\n                messagePayload.append(tlvData)\n                \n                let encrypted = try noiseService.encrypt(messagePayload, for: recipientID)\n                \n                // Convert recipientID to Data (assuming it's a hex string)\n                var recipientData = Data()\n                var tempID = recipientID.id\n                while tempID.count >= 2 {\n                    let hexByte = String(tempID.prefix(2))\n                    if let byte = UInt8(hexByte, radix: 16) {\n                        recipientData.append(byte)\n                    }\n                    tempID = String(tempID.dropFirst(2))\n                }\n                if tempID.count == 1 {\n                    if let byte = UInt8(tempID, radix: 16) {\n                        recipientData.append(byte)\n                    }\n                }\n\n                let packet = BitchatPacket(\n                    type: MessageType.noiseEncrypted.rawValue,\n                    senderID: myPeerIDData,\n                    recipientID: recipientData,\n                    timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                    payload: encrypted,\n                    signature: nil,\n                    ttl: messageTTL\n                )\n                \n                broadcastPacket(packet)\n                \n                // Notify delegate that message was sent\n                notifyUI { [weak self] in\n                    self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)\n                }\n            } catch {\n                SecureLogger.error(\"Failed to encrypt message: \\(error)\")\n            }\n        } else {\n            // Queue message for sending after handshake completes\n            SecureLogger.debug(\"🤝 No session with \\(recipientID), initiating handshake and queueing message\", category: .session)\n            \n            // Queue the message (especially important for favorite notifications)\n            collectionsQueue.sync(flags: .barrier) {\n                if pendingMessagesAfterHandshake[recipientID] == nil {\n                    pendingMessagesAfterHandshake[recipientID] = []\n                }\n                pendingMessagesAfterHandshake[recipientID]?.append((content, messageID))\n            }\n            \n            initiateNoiseHandshake(with: recipientID)\n            \n            // Notify delegate that message is pending\n            notifyUI { [weak self] in\n                self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending)\n            }\n        }\n    }\n    \n    private func initiateNoiseHandshake(with peerID: PeerID) {\n        // Use NoiseEncryptionService for handshake\n        guard !noiseService.hasSession(with: peerID) else { return }\n        \n        do {\n            let handshakeData = try noiseService.initiateHandshake(with: peerID)\n            \n            // Send handshake init\n            let packet = BitchatPacket(\n                type: MessageType.noiseHandshake.rawValue,\n                senderID: myPeerIDData,\n                recipientID: Data(hexString: peerID.id),\n                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                payload: handshakeData,\n                signature: nil,\n                ttl: messageTTL\n            )\n            broadcastPacket(packet)\n        } catch {\n            SecureLogger.error(\"Failed to initiate handshake: \\(error)\")\n        }\n    }\n    \n    private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {\n        // Atomically take all pending messages to process (prevents concurrent modification)\n        let pendingMessages = collectionsQueue.sync(flags: .barrier) { () -> [(content: String, messageID: String)]? in\n            let messages = pendingMessagesAfterHandshake[peerID]\n            pendingMessagesAfterHandshake.removeValue(forKey: peerID)\n            return messages\n        }\n\n        guard let messages = pendingMessages, !messages.isEmpty else { return }\n\n        SecureLogger.debug(\"📤 Sending \\(messages.count) pending messages after handshake to \\(peerID)\", category: .session)\n\n        // Track failed messages for re-queuing\n        var failedMessages: [(content: String, messageID: String)] = []\n\n        // Send each pending message directly (we know session is established)\n        for (content, messageID) in messages {\n            do {\n                // Use the same TLV format as normal sends to keep receiver decoding consistent\n                let privateMessage = PrivateMessagePacket(messageID: messageID, content: content)\n                guard let tlvData = privateMessage.encode() else {\n                    SecureLogger.error(\"Failed to encode pending private message TLV\")\n                    failedMessages.append((content, messageID))\n                    continue\n                }\n\n                var messagePayload = Data([NoisePayloadType.privateMessage.rawValue])\n                messagePayload.append(tlvData)\n\n                let encrypted = try noiseService.encrypt(messagePayload, for: peerID)\n\n                let packet = BitchatPacket(\n                    type: MessageType.noiseEncrypted.rawValue,\n                    senderID: myPeerIDData,\n                    recipientID: Data(hexString: peerID.id),\n                    timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                    payload: encrypted,\n                    signature: nil,\n                    ttl: messageTTL\n                )\n\n                // We're already on messageQueue from the callback\n                broadcastPacket(packet)\n\n                // Notify delegate that message was sent\n                notifyUI { [weak self] in\n                    self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)\n                }\n\n                SecureLogger.debug(\"✅ Sent pending message \\(messageID) to \\(peerID) after handshake\", category: .session)\n            } catch {\n                SecureLogger.error(\"Failed to send pending message after handshake: \\(error)\")\n                failedMessages.append((content, messageID))\n\n                // Notify delegate of failure\n                notifyUI { [weak self] in\n                    self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: \"Encryption failed\"))\n                }\n            }\n        }\n\n        // Re-queue any failed messages for retry on next handshake\n        if !failedMessages.isEmpty {\n            collectionsQueue.async(flags: .barrier) { [weak self] in\n                guard let self = self else { return }\n                if self.pendingMessagesAfterHandshake[peerID] == nil {\n                    self.pendingMessagesAfterHandshake[peerID] = []\n                }\n                // Prepend failed messages to maintain order\n                self.pendingMessagesAfterHandshake[peerID]?.insert(contentsOf: failedMessages, at: 0)\n                SecureLogger.warning(\"⚠️ Re-queued \\(failedMessages.count) failed messages for \\(peerID)\", category: .session)\n            }\n        }\n    }\n    \n    // MARK: Fragmentation (Required for messages > BLE MTU)\n    \n    private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil, transferId: String? = nil) {\n        let context = PendingFragmentTransfer(packet: packet, pad: pad, maxChunk: maxChunk, directedPeer: directedOnlyPeer, transferId: transferId)\n        if packet.type == MessageType.fileTransfer.rawValue {\n            let shouldQueue = collectionsQueue.sync {\n                self.activeTransfers.count >= TransportConfig.bleMaxConcurrentTransfers\n            }\n            if shouldQueue {\n                queueFragmentTransfer(context, prioritizeFront: false)\n                return\n            }\n        }\n        startFragmentedPacket(context)\n    }\n\n    private func queueFragmentTransfer(_ context: PendingFragmentTransfer, prioritizeFront: Bool) {\n        collectionsQueue.async(flags: .barrier) { [weak self] in\n            guard let self = self else { return }\n            if prioritizeFront {\n                self.pendingFragmentTransfers.insert(context, at: 0)\n            } else {\n                self.pendingFragmentTransfers.append(context)\n            }\n        }\n        if let transferId = context.transferId {\n            SecureLogger.debug(\"🚦 Queued media transfer \\(transferId.prefix(8))… waiting for slot\", category: .session)\n        } else {\n            SecureLogger.debug(\"🚦 Queued fragment transfer waiting for slot\", category: .session)\n        }\n    }\n\n    private func startFragmentedPacket(_ context: PendingFragmentTransfer) {\n        let packet = context.packet\n        let isFileTransfer = packet.type == MessageType.fileTransfer.rawValue\n        var reservedTransferId: String?\n\n        let releaseReservedSlot: (String) -> Void = { id in\n            TransferProgressManager.shared.cancel(id: id)\n            self.collectionsQueue.async(flags: .barrier) { [weak self] in\n                self?.activeTransfers.removeValue(forKey: id)\n            }\n            self.messageQueue.async { [weak self] in\n                self?.startNextPendingTransferIfNeeded()\n            }\n        }\n\n        if isFileTransfer {\n            let candidateId = context.transferId ?? packet.payload.sha256Hex()\n            var didReserve = false\n            collectionsQueue.sync(flags: .barrier) {\n                if self.activeTransfers.count < TransportConfig.bleMaxConcurrentTransfers,\n                   self.activeTransfers[candidateId] == nil {\n                    self.activeTransfers[candidateId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])\n                    didReserve = true\n                }\n            }\n            guard didReserve else {\n                queueFragmentTransfer(context, prioritizeFront: true)\n                return\n            }\n            reservedTransferId = candidateId\n        }\n\n        guard let fullData = packet.toBinaryData(padding: context.pad) else {\n            if let id = reservedTransferId {\n                releaseReservedSlot(id)\n            }\n            return\n        }\n        // Fragment the unpadded frame; each fragment will be encoded independently\n        let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })\n        // Dynamic Fragment Sizing (Source Routing v2)\n        // See docs/SOURCE_ROUTING.md Section 5.1\n        var fragmentVersion: UInt8 = 1\n        var calculatedChunk = defaultFragmentSize\n\n        if let route = packet.route, !route.isEmpty {\n            fragmentVersion = 2\n            // RouteSize = 1 + (Hops * 8)\n            let routeSize = 1 + (route.count * 8)\n            // Overhead = HeaderV2(16) + SenderID(8) + RecipientID(8) + RouteSize + FragmentHeader(13) + PaddingBuffer(16)\n            let overhead = 16 + 8 + 8 + routeSize + 13 + 16\n            calculatedChunk = max(64, bleMaxMTU - overhead)\n        }\n\n        let chunk = context.maxChunk ?? calculatedChunk\n        let safeChunk = max(64, chunk)\n        let fragments = stride(from: 0, to: fullData.count, by: safeChunk).map { offset in\n            Data(fullData[offset..<min(offset + safeChunk, fullData.count)])\n        }\n        guard !fragments.isEmpty else {\n            if let id = reservedTransferId {\n                releaseReservedSlot(id)\n            }\n            return\n        }\n\n        // Lightweight pacing to reduce floods and allow BLE buffers to drain\n        // Also briefly pause scanning during long fragment trains to save battery\n        let totalFragments = fragments.count\n        if totalFragments > 4 {\n            bleQueue.async { [weak self] in\n                guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }\n                if c.isScanning { c.stopScan() }\n                let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs)\n                self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in\n                    self?.startScanning()\n                }\n            }\n        }\n        let perFragMs = (context.directedPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs\n\n        let transferIdentifier: String? = {\n            guard let id = reservedTransferId else { return nil }\n            collectionsQueue.sync(flags: .barrier) {\n                self.activeTransfers[id] = ActiveTransferState(totalFragments: totalFragments, sentFragments: 0, workItems: [])\n            }\n            TransferProgressManager.shared.start(id: id, totalFragments: totalFragments)\n            return id\n        }()\n\n        var scheduledItems: [(item: DispatchWorkItem, index: Int)] = []\n\n        for (index, fragment) in fragments.enumerated() {\n            var payload = Data()\n            payload.append(fragmentID)\n            payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })\n            payload.append(contentsOf: withUnsafeBytes(of: UInt16(fragments.count).bigEndian) { Data($0) })\n            payload.append(packet.type)\n            payload.append(fragment)\n\n            let fragmentRecipient: Data? = {\n                if let only = context.directedPeer { return Data(hexString: only.id) }\n                return packet.recipientID\n            }()\n\n            let fragmentPacket = BitchatPacket(\n                type: MessageType.fragment.rawValue,\n                senderID: packet.senderID,\n                recipientID: fragmentRecipient,\n                timestamp: packet.timestamp,\n                payload: payload,\n                signature: nil,\n                ttl: packet.ttl,\n                version: fragmentVersion,\n                route: packet.route,\n                isRSR: packet.isRSR\n            )\n\n            let workItem = DispatchWorkItem { [weak self] in\n                guard let self = self else { return }\n                if let transferId = transferIdentifier {\n                    let isActive = self.collectionsQueue.sync { self.activeTransfers[transferId] != nil }\n                    guard isActive else { return }\n                }\n                if fragmentRecipient == nil || fragmentRecipient?.allSatisfy({ $0 == 0xFF }) == true {\n                    self.gossipSyncManager?.onPublicPacketSeen(fragmentPacket)\n                }\n                self.broadcastPacket(fragmentPacket)\n                if let transferId = transferIdentifier {\n                    self.markFragmentSent(transferId: transferId)\n                }\n            }\n\n            scheduledItems.append((item: workItem, index: index))\n        }\n\n        if let transferId = transferIdentifier {\n            let workItems = scheduledItems.map { $0.item }\n            collectionsQueue.async(flags: .barrier) { [weak self] in\n                guard let self = self, var state = self.activeTransfers[transferId] else { return }\n                state.workItems = workItems\n                self.activeTransfers[transferId] = state\n            }\n        }\n\n        for (workItem, index) in scheduledItems {\n            let delayMs = index * perFragMs\n            messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)\n        }\n    }\n    \n    // MARK: - Fragmentation (Required for messages > BLE MTU)\n\n    private func markFragmentSent(transferId: String) {\n        collectionsQueue.async(flags: .barrier) { [weak self] in\n            guard let self = self, var state = self.activeTransfers[transferId] else { return }\n            state.sentFragments = min(state.sentFragments + 1, state.totalFragments)\n            let isComplete = state.sentFragments >= state.totalFragments\n            if isComplete {\n                self.activeTransfers.removeValue(forKey: transferId)\n            } else {\n                self.activeTransfers[transferId] = state\n            }\n            TransferProgressManager.shared.recordFragmentSent(id: transferId)\n            if isComplete {\n                self.messageQueue.async { [weak self] in\n                    self?.startNextPendingTransferIfNeeded()\n                }\n            }\n        }\n    }\n\n    private func startNextPendingTransferIfNeeded() {\n        collectionsQueue.async(flags: .barrier) { [weak self] in\n            guard let self = self else { return }\n            let limit = TransportConfig.bleMaxConcurrentTransfers\n            var availableSlots = max(0, limit - self.activeTransfers.count)\n            guard availableSlots > 0, !self.pendingFragmentTransfers.isEmpty else { return }\n            var toStart: [PendingFragmentTransfer] = []\n            while availableSlots > 0, !self.pendingFragmentTransfers.isEmpty {\n                toStart.append(self.pendingFragmentTransfers.removeFirst())\n                availableSlots -= 1\n            }\n            for context in toStart {\n                self.messageQueue.async { [weak self] in\n                    self?.startFragmentedPacket(context)\n                }\n            }\n        }\n    }\n    \n    private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {\n        if DispatchQueue.getSpecific(key: messageQueueKey) != nil {\n            _handleFragment(packet, from: peerID)\n        } else {\n            messageQueue.async(flags: .barrier) { [weak self] in\n                self?._handleFragment(packet, from: peerID)\n            }\n        }\n    }\n\n    private func _handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {\n        // Don't process our own fragments\n        if peerID == myPeerID {\n            return\n        }\n\n        // Minimum header: 8 bytes ID + 2 index + 2 total + 1 type\n        guard packet.payload.count >= 13 else { return }\n\n        // Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian\n        var senderU64: UInt64 = 0\n        for b in packet.senderID.prefix(8) { senderU64 = (senderU64 << 8) | UInt64(b) }\n        var fragU64: UInt64 = 0\n        for b in packet.payload.prefix(8) { fragU64 = (fragU64 << 8) | UInt64(b) }\n        // Parse big-endian UInt16 safely without alignment assumptions\n        let idxHi = UInt16(packet.payload[8])\n        let idxLo = UInt16(packet.payload[9])\n        let index = Int((idxHi << 8) | idxLo)\n        let totHi = UInt16(packet.payload[10])\n        let totLo = UInt16(packet.payload[11])\n        let total = Int((totHi << 8) | totLo)\n        let originalType = packet.payload[12]\n        let fragmentData = packet.payload.suffix(from: 13)\n\n        // Sanity checks - add reasonable upper bound on total to prevent DoS\n        guard total > 0 && total <= 10000 && index >= 0 && index < total else { return }\n\n        let isBroadcastFragment: Bool = {\n            guard let recipient = packet.recipientID else { return true }\n            return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }\n        }()\n        if isBroadcastFragment {\n            gossipSyncManager?.onPublicPacketSeen(packet)\n        }\n\n        // Compute fragment key for this assembly\n        let key = FragmentKey(sender: senderU64, id: fragU64)\n\n        // Critical section: Store fragment and check completion status\n        var shouldReassemble: Bool = false\n        var fragmentsToReassemble: [Int: Data]? = nil\n\n        collectionsQueue.sync(flags: .barrier) {\n            if incomingFragments[key] == nil {\n                // Cap in-flight assemblies to prevent memory/battery blowups\n                if incomingFragments.count >= maxInFlightAssemblies {\n                    // Evict the oldest assembly by timestamp\n                    if let oldest = fragmentMetadata.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {\n                        incomingFragments.removeValue(forKey: oldest)\n                        fragmentMetadata.removeValue(forKey: oldest)\n                    }\n                }\n                incomingFragments[key] = [:]\n                fragmentMetadata[key] = (originalType, total, Date())\n                SecureLogger.debug(\"📦 Started fragment assembly id=\\(String(format: \"%016llx\", fragU64)) total=\\(total)\", category: .session)\n            }\n\n            // Check cumulative size before storing this fragment\n            let currentSize = incomingFragments[key]?.values.reduce(0) { $0 + $1.count } ?? 0\n            let assemblyLimit: Int = {\n                if originalType == MessageType.fileTransfer.rawValue {\n                    // Allow headroom for TLV metadata and binary framing overhead.\n                    return FileTransferLimits.maxFramedFileBytes\n                }\n                return FileTransferLimits.maxPayloadBytes\n            }()\n            let projectedSize = currentSize + fragmentData.count\n            guard projectedSize <= assemblyLimit else {\n                // Exceeds size limit - evict this assembly\n                SecureLogger.warning(\n                    \"🚫 Fragment assembly exceeds size limit (\\(projectedSize) bytes > \\(assemblyLimit)), evicting. Type=\\(originalType) Index=\\(index)/\\(total)\",\n                    category: .security\n                )\n                incomingFragments.removeValue(forKey: key)\n                fragmentMetadata.removeValue(forKey: key)\n                shouldReassemble = false\n                fragmentsToReassemble = nil\n                return\n            }\n\n            incomingFragments[key]?[index] = Data(fragmentData)\n            SecureLogger.debug(\"📦 Fragment \\(index + 1)/\\(total) (len=\\(fragmentData.count)) for id=\\(String(format: \"%016llx\", fragU64))\", category: .session)\n\n            // Check if complete\n            if let fragments = incomingFragments[key], fragments.count == total {\n                shouldReassemble = true\n                fragmentsToReassemble = fragments\n            } else {\n                shouldReassemble = false\n                fragmentsToReassemble = nil\n            }\n        }\n\n        // Heavy work outside lock: reassemble and decode\n        guard shouldReassemble, let fragments = fragmentsToReassemble else { return }\n\n        var reassembled = Data()\n        for i in 0..<total {\n            if let fragment = fragments[i] {\n                reassembled.append(fragment)\n            }\n        }\n\n        // Decode the original packet bytes we reassembled, so flags/compression are preserved\n        if var originalPacket = BinaryProtocol.decode(reassembled) {\n            \n            // Reassembled packet validation\n            let innerSender = PeerID(hexData: originalPacket.senderID)\n            if !validatePacket(originalPacket, from: innerSender) {\n                // Cleanup below\n            } else {\n                SecureLogger.debug(\"✅ Reassembled packet id=\\(String(format: \"%016llx\", fragU64)) type=\\(originalPacket.type) bytes=\\(reassembled.count)\", category: .session)\n                originalPacket.ttl = 0\n                handleReceivedPacket(originalPacket, from: peerID)\n            }\n        } else {\n            SecureLogger.error(\"❌ Failed to decode reassembled packet (type=\\(originalType), total=\\(total))\", category: .session)\n        }\n\n        // Critical section: Cleanup completed assembly\n        collectionsQueue.sync(flags: .barrier) {\n            incomingFragments.removeValue(forKey: key)\n            fragmentMetadata.removeValue(forKey: key)\n        }\n    }\n    \n    // MARK: Packet Reception\n    \n    private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {\n        // Call directly if already on messageQueue, otherwise dispatch\n        if DispatchQueue.getSpecific(key: messageQueueKey) == nil {\n            messageQueue.async { [weak self] in\n                self?.handleReceivedPacket(packet, from: peerID)\n            }\n            return\n        }\n\n        \n        // Deduplication (thread-safe)\n        let senderID = PeerID(hexData: packet.senderID)\n        // Include packet type in message ID to prevent collisions between different packet types\n        let messageID = \"\\(senderID)-\\(packet.timestamp)-\\(packet.type)\"\n        \n        // Only log non-announce packets to reduce noise\n        if packet.type != MessageType.announce.rawValue {\n            // Log packet details for debugging\n            SecureLogger.debug(\"📦 Handling packet type \\(packet.type) from \\(senderID), messageID: \\(messageID)\", category: .session)\n        }\n        \n        // Efficient deduplication\n        // Important: do not dedup fragment packets globally (each piece must pass)\n        // Special case: allow our own packets recovered via sync (TTL==0) to pass\n        // through even if we've marked them as seen at send time.\n        let allowSelfSyncReplay = (packet.ttl == 0) && (senderID == myPeerID)\n        if packet.type != MessageType.fragment.rawValue && !allowSelfSyncReplay && messageDeduplicator.isDuplicate(messageID) {\n            // Announce packets (type 1) are sent every 10 seconds for peer discovery\n            // It's normal to see these as duplicates - don't log them to reduce noise\n            if packet.type != MessageType.announce.rawValue {\n                SecureLogger.debug(\"⚠️ Duplicate packet ignored: \\(messageID)\", category: .session)\n            }\n            // In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging.\n            // In denser graphs, cancel the pending relay to reduce redundant floods.\n            let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }\n            if connectedCount > 2 {\n                collectionsQueue.async(flags: .barrier) { [weak self] in\n                    if let task = self?.scheduledRelays.removeValue(forKey: messageID) {\n                        task.cancel()\n                    }\n                }\n            }\n            return // Duplicate ignored\n        }\n        \n        // Update peer info without verbose logging - update the peer we received from, not the original sender\n        updatePeerLastSeen(peerID)\n\n        // Track recent traffic timestamps for adaptive behavior\n        collectionsQueue.async(flags: .barrier) { [weak self] in\n            guard let self = self else { return }\n            let now = Date()\n            self.recentPacketTimestamps.append(now)\n            // keep last N timestamps within window\n            let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)\n            if self.recentPacketTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {\n                self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)\n            }\n            self.recentPacketTimestamps.removeAll { $0 < cutoff }\n        }\n\n        \n        // Process by type\n        switch MessageType(rawValue: packet.type) {\n        case .announce:\n            handleAnnounce(packet, from: senderID)\n            \n        case .message:\n            handleMessage(packet, from: senderID)\n            \n        case .requestSync:\n            handleRequestSync(packet, from: senderID)\n            \n        case .noiseHandshake:\n            handleNoiseHandshake(packet, from: senderID)\n            \n        case .noiseEncrypted:\n            handleNoiseEncrypted(packet, from: senderID)\n            \n        case .fragment:\n            handleFragment(packet, from: senderID)\n            \n        case .fileTransfer:\n            handleFileTransfer(packet, from: senderID)\n            \n        case .leave:\n            handleLeave(packet, from: senderID)\n            \n        case .none:\n            SecureLogger.warning(\"⚠️ Unknown message type: \\(packet.type)\", category: .session)\n            break\n        }\n        \n        if forwardAlongRouteIfNeeded(packet) {\n            return\n        }\n        \n        // Relay if TTL > 1 and we're not the original sender\n        // Relay decision and scheduling (extracted via RelayController)\n        do {\n            let degree = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }\n            let decision = RelayController.decide(\n                ttl: packet.ttl,\n                senderIsSelf: senderID == myPeerID,\n                isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,\n                isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),\n                isFragment: packet.type == MessageType.fragment.rawValue,\n                isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,\n                isHandshake: packet.type == MessageType.noiseHandshake.rawValue,\n                isAnnounce: packet.type == MessageType.announce.rawValue,\n                degree: degree,\n                highDegreeThreshold: highDegreeThreshold\n            )\n            guard decision.shouldRelay else { return }\n            let work = DispatchWorkItem { [weak self] in\n                guard let self = self else { return }\n                // Remove scheduled task before executing\n                self.collectionsQueue.async(flags: .barrier) { [weak self] in\n                    _ = self?.scheduledRelays.removeValue(forKey: messageID)\n                }\n                var relayPacket = packet\n                relayPacket.ttl = decision.newTTL\n                self.broadcastPacket(relayPacket)\n            }\n            // Track the scheduled relay so duplicates can cancel it\n            collectionsQueue.async(flags: .barrier) { [weak self] in\n                self?.scheduledRelays[messageID] = work\n            }\n            messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)\n        }\n    }\n    \n    private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {\n        guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {\n            SecureLogger.error(\"❌ Failed to decode announce packet from \\(peerID)\", category: .session)\n            return\n        }\n        \n        // Verify that the sender's derived ID from the announced noise public key matches the packet senderID\n        // This helps detect relayed or spoofed announces. Only warn in release; assert in debug.\n        let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey)\n        if derivedFromKey != peerID {\n            SecureLogger.warning(\"⚠️ Announce sender mismatch: derived \\(derivedFromKey.id.prefix(8))… vs packet \\(peerID.id.prefix(8))…\", category: .security)\n            return\n        }\n        \n        // Don't add ourselves as a peer\n        if peerID == myPeerID {\n            return\n        }\n\n        // Reject stale announces to prevent ghost peers from appearing\n        // Use same 15-minute window as gossip sync (900 seconds)\n        let maxAnnounceAgeSeconds: TimeInterval = 900\n        let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)\n        let ageThresholdMs = UInt64(maxAnnounceAgeSeconds * 1000)\n        if nowMs >= ageThresholdMs {\n            let cutoffMs = nowMs - ageThresholdMs\n            if packet.timestamp < cutoffMs {\n                SecureLogger.debug(\"⏰ Ignoring stale announce from \\(peerID.id.prefix(8))… (age: \\(Double(nowMs - packet.timestamp) / 1000.0)s)\", category: .session)\n                return\n            }\n        }\n\n        // Suppress announce logs to reduce noise\n\n        // Precompute signature verification outside barrier to reduce contention\n        let existingPeerForVerify = collectionsQueue.sync { peers[peerID] }\n        var verifiedAnnounce = false\n        if packet.signature != nil {\n            verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)\n            if !verifiedAnnounce {\n                SecureLogger.warning(\"⚠️ Signature verification for announce failed \\(peerID.id.prefix(8))\", category: .security)\n            }\n        }\n        if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey {\n            SecureLogger.warning(\"⚠️ Announce key mismatch for \\(peerID.id.prefix(8))… — keeping unverified\", category: .security)\n            verifiedAnnounce = false\n        }\n\n        // Track if this is a new or reconnected peer\n        var isNewPeer = false\n        var isReconnectedPeer = false\n        let directLinkState = linkState(for: peerID)\n        \n        collectionsQueue.sync(flags: .barrier) {\n            // Check if we have an actual BLE connection to this peer\n            let hasPeripheralConnection = directLinkState.hasPeripheral\n            \n            // Check if this peer is subscribed to us as a central\n            // Note: We can't identify which specific central is which peer without additional mapping\n            let hasCentralSubscription = directLinkState.hasCentral\n            \n            // Direct announces arrive with full TTL (no prior hop)\n            let isDirectAnnounce = (packet.ttl == messageTTL)\n            \n            // Check if we already have this peer (might be reconnecting)\n            let existingPeer = peers[peerID]\n            let wasDisconnected = existingPeer?.isConnected == false\n            \n            // Set flags for use outside the sync block\n            isNewPeer = (existingPeer == nil)\n            isReconnectedPeer = wasDisconnected\n            \n            // Use precomputed verification result\n            let verified = verifiedAnnounce\n\n            // Require verified announce; ignore otherwise (no backward compatibility)\n            if !verified {\n                SecureLogger.warning(\"❌ Ignoring unverified announce from \\(peerID.id.prefix(8))…\", category: .security)\n                // Reset flags to prevent post-barrier code from acting on unverified announces\n                isNewPeer = false\n                isReconnectedPeer = false\n                return\n            }\n\n            // Update or create peer info\n            if let existing = existingPeer, existing.isConnected {\n                // Update lastSeen and identity info\n                peers[peerID] = PeerInfo(\n                    peerID: existing.peerID,\n                    nickname: announcement.nickname,\n                    isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,\n                    noisePublicKey: announcement.noisePublicKey,\n                    signingPublicKey: announcement.signingPublicKey,\n                    isVerifiedNickname: true,\n                    lastSeen: Date()\n                )\n            } else {\n                // New peer or reconnecting peer\n                peers[peerID] = PeerInfo(\n                    peerID: peerID,\n                    nickname: announcement.nickname,\n                    isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,\n                    noisePublicKey: announcement.noisePublicKey,\n                    signingPublicKey: announcement.signingPublicKey,\n                    isVerifiedNickname: true,\n                    lastSeen: Date()\n                )\n            }\n            \n            // Log connection status only for direct connectivity changes; debounce to reduce spam\n            if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {\n                let now = Date()\n                if existingPeer == nil {\n                    SecureLogger.debug(\"🆕 New peer: \\(announcement.nickname)\", category: .session)\n                } else if wasDisconnected {\n                    // Debounce 'reconnected' logs within short window\n                    if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds {\n                        // Skip duplicate log\n                    } else {\n                        SecureLogger.debug(\"🔄 Peer \\(announcement.nickname) reconnected\", category: .session)\n                        lastReconnectLogAt[peerID] = now\n                    }\n                } else if existingPeer?.nickname != announcement.nickname {\n                    SecureLogger.debug(\"🔄 Peer \\(peerID) changed nickname: \\(existingPeer?.nickname ?? \"Unknown\") -> \\(announcement.nickname)\", category: .session)\n                }\n            }\n        }\n\n        // Update topology with verified neighbor claims (only for authenticated announces)\n        if verifiedAnnounce, let neighbors = announcement.directNeighbors {\n            meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors)\n        }\n\n        // Persist cryptographic identity and signing key for robust offline verification\n        identityManager.upsertCryptographicIdentity(\n            fingerprint: announcement.noisePublicKey.sha256Fingerprint(),\n            noisePublicKey: announcement.noisePublicKey,\n            signingPublicKey: announcement.signingPublicKey,\n            claimedNickname: announcement.nickname\n        )\n\n        // Notify UI on main thread\n        notifyUI { [weak self] in\n            guard let self = self else { return }\n            \n            // Get current peer list (after addition)\n            let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }\n            \n            // Only notify of connection for new or reconnected peers when it is a direct announce\n            if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {\n                self.delegate?.didConnectToPeer(peerID)\n                // Schedule initial unicast sync to this peer\n                self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)\n            }\n            \n            self.requestPeerDataPublish()\n            self.delegate?.didUpdatePeerList(currentPeerIDs)\n        }\n        \n        // Track for sync (include our own and others' announces)\n        gossipSyncManager?.onPublicPacketSeen(packet)\n\n        // Send announce back for bidirectional discovery (only once per peer)\n        let announceBackID = \"announce-back-\\(peerID)\"\n        let shouldSendBack = !messageDeduplicator.contains(announceBackID)\n        if shouldSendBack {\n            messageDeduplicator.markProcessed(announceBackID)\n        }\n        \n        if shouldSendBack {\n            // Reciprocate announce for bidirectional discovery\n            // Force send to ensure the peer receives our announce\n            sendAnnounce(forceSend: true)\n        }\n\n        // Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop\n        if isNewPeer {\n            let delay = Double.random(in: 0.3...0.6)\n            messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in\n                self?.sendAnnounce(forceSend: true)\n            }\n        }\n    }\n\n    // Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager\n    private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) {\n        guard let req = RequestSyncPacket.decode(from: packet.payload) else {\n            SecureLogger.warning(\"⚠️ Malformed REQUEST_SYNC from \\(peerID)\", category: .session)\n            return\n        }\n        gossipSyncManager?.handleRequestSync(from: peerID, request: req)\n    }\n    \n    // Mention parsing moved to ChatViewModel\n    \n    private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) {\n        // Ignore self-origin public messages except when returned via sync (TTL==0).\n        // This allows our own messages to be surfaced when they come back via\n        // the sync path without re-processing regular relayed copies.\n        if peerID == myPeerID && packet.ttl != 0 { return }\n\n        // Reject stale broadcast messages to prevent old messages from appearing\n        // Use same 15-minute window as gossip sync (900 seconds)\n        // Check if this is a broadcast message (recipient is all 0xFF or nil)\n        let isBroadcast: Bool = {\n            guard let r = packet.recipientID else { return true }\n            return r.count == 8 && r.allSatisfy { $0 == 0xFF }\n        }()\n        if isBroadcast {\n            let maxMessageAgeSeconds: TimeInterval = 900\n            let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)\n            let ageThresholdMs = UInt64(maxMessageAgeSeconds * 1000)\n            if nowMs >= ageThresholdMs {\n                let cutoffMs = nowMs - ageThresholdMs\n                if packet.timestamp < cutoffMs {\n                    SecureLogger.debug(\"⏰ Ignoring stale broadcast message from \\(peerID.id.prefix(8))… (age: \\(Double(nowMs - packet.timestamp) / 1000.0)s)\", category: .session)\n                    return\n                }\n            }\n        }\n\n        var accepted = false\n        var senderNickname: String = \"\"\n        // Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.\n        let peersSnapshot = collectionsQueue.sync { peers }\n\n        // If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately\n        if peerID == myPeerID {\n            accepted = true\n            senderNickname = myNickname\n        }\n        else if let info = peersSnapshot[peerID], info.isVerifiedNickname {\n            // Known verified peer path\n            accepted = true\n            senderNickname = info.nickname\n            // Handle nickname collisions\n            let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)\n            if hasCollision {\n                senderNickname += \"#\" + String(peerID.id.prefix(4))\n            }\n        } else {\n            // Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix\n            if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {\n                // Find candidate identities by peerID prefix (16 hex)\n                let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)\n                for candidate in candidates {\n                    if let signingKey = candidate.signingPublicKey,\n                       noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {\n                        accepted = true\n                        // Prefer persisted social petname or claimed nickname\n                        if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) {\n                            senderNickname = social.localPetname ?? social.claimedNickname\n                        } else {\n                            senderNickname = \"anon\" + String(peerID.id.prefix(4))\n                        }\n                        break\n                    }\n                }\n            }\n        }\n\n        guard accepted else {\n            SecureLogger.warning(\"🚫 Dropping public message from unverified or unknown peer \\(peerID.id.prefix(8))…\", category: .security)\n            return\n        }\n\n        let isBroadcastRecipient: Bool = {\n            guard let r = packet.recipientID else { return true }\n            return r.count == 8 && r.allSatisfy { $0 == 0xFF }\n        }()\n        if isBroadcastRecipient && packet.type == MessageType.message.rawValue {\n            gossipSyncManager?.onPublicPacketSeen(packet)\n        }\n\n        guard let content = String(data: packet.payload, encoding: .utf8) else {\n            SecureLogger.error(\"❌ Failed to decode message payload as UTF-8\", category: .session)\n            return\n        }\n        // Determine if we have a direct link to the sender\n        let directLink = linkState(for: peerID)\n        let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral\n\n        let pathTag = hasDirectLink ? \"direct\" : \"mesh\"\n        SecureLogger.debug(\"💬 [\\(senderNickname)] TTL:\\(packet.ttl) (\\(pathTag)): \\(String(content.prefix(50)))\\(content.count > 50 ? \"...\" : \"\")\", category: .session)\n\n        let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)\n        var resolvedSelfMessageID: String? = nil\n        if peerID == myPeerID {\n            let senderHex = packet.senderID.hexEncodedString()\n            let dedupID = \"\\(senderHex)-\\(packet.timestamp)-\\(packet.type)\"\n            resolvedSelfMessageID = selfBroadcastMessageIDs.removeValue(forKey: dedupID)?.id\n        }\n        notifyUI { [weak self] in\n            self?.delegate?.didReceivePublicMessage(from: peerID,\n                                                    nickname: senderNickname,\n                                                    content: content,\n                                                    timestamp: ts,\n                                                    messageID: resolvedSelfMessageID)\n        }\n    }\n    \n    private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {\n        // Use NoiseEncryptionService for handshake processing\n        if PeerID(hexData: packet.recipientID) == myPeerID {\n            // Handshake is for us\n            do {\n                if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) {\n                    // Send response\n                    let responsePacket = BitchatPacket(\n                        type: MessageType.noiseHandshake.rawValue,\n                        senderID: myPeerIDData,\n                        recipientID: Data(hexString: peerID.id),\n                        timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                        payload: response,\n                        signature: nil,\n                        ttl: messageTTL\n                    )\n                    // We're on messageQueue from delegate callback\n                    broadcastPacket(responsePacket)\n                }\n                \n                // Session establishment will trigger onPeerAuthenticated callback\n                // which will send any pending messages at the right time\n            } catch {\n                SecureLogger.error(\"Failed to process handshake: \\(error)\")\n                // Try initiating a new handshake\n                if !noiseService.hasSession(with: peerID) {\n                    initiateNoiseHandshake(with: peerID)\n                }\n            }\n        }\n    }\n    \n    private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {\n        SecureLogger.debug(\"🔐 handleNoiseEncrypted called for packet from \\(peerID)\")\n        \n        guard let recipientID = PeerID(hexData: packet.recipientID) else {\n            SecureLogger.warning(\"⚠️ Encrypted message has no recipient ID\", category: .session)\n            return\n        }\n        \n        if recipientID != myPeerID {\n            SecureLogger.debug(\"🔐 Encrypted message not for me (for \\(recipientID), I am \\(myPeerID))\", category: .session)\n            return\n        }\n        \n        // Update lastSeen for the peer we received from (important for private messages)\n        updatePeerLastSeen(peerID)\n        \n        do {\n            let decrypted = try noiseService.decrypt(packet.payload, from: peerID)\n            guard decrypted.count > 0 else { return }\n            \n            // First byte indicates the payload type\n            let payloadType = decrypted[0]\n            let payloadData = decrypted.dropFirst()\n            \n            switch NoisePayloadType(rawValue: payloadType) {\n            case .privateMessage:\n                let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)\n                notifyUI { [weak self] in\n                    self?.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)\n                }\n            case .delivered:\n                let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)\n                notifyUI { [weak self] in\n                    self?.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)\n                }\n            case .readReceipt:\n                let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)\n                notifyUI { [weak self] in\n                    self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)\n                }\n            case .verifyChallenge:\n                let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)\n                notifyUI { [weak self] in\n                    self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)\n                }\n            case .verifyResponse:\n                let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)\n                notifyUI { [weak self] in\n                    self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)\n                }\n            case .none:\n                SecureLogger.warning(\"⚠️ Unknown noise payload type: \\(payloadType)\")\n            }\n        } catch NoiseEncryptionError.sessionNotEstablished {\n            // We received an encrypted message before establishing a session with this peer.\n            // Trigger a handshake so future messages can be decrypted.\n            SecureLogger.debug(\"🔑 Encrypted message from \\(peerID) without session; initiating handshake\")\n            if !noiseService.hasSession(with: peerID) {\n                initiateNoiseHandshake(with: peerID)\n            }\n        } catch {\n            // Decryption failed - clear the corrupted session and re-initiate handshake\n            // This handles cases where session state got out of sync (nonce mismatch, etc.)\n            SecureLogger.error(\"❌ Failed to decrypt message from \\(peerID): \\(error) - clearing session and re-initiating handshake\")\n            noiseService.clearSession(for: peerID)\n            initiateNoiseHandshake(with: peerID)\n        }\n    }\n\n    // MARK: Helper Functions\n    \n    private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) {\n        let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in\n            let list = pendingNoisePayloadsAfterHandshake[peerID] ?? []\n            pendingNoisePayloadsAfterHandshake.removeValue(forKey: peerID)\n            return list\n        }\n        guard !payloads.isEmpty else { return }\n        SecureLogger.debug(\"📤 Sending \\(payloads.count) pending noise payloads to \\(peerID) after handshake\", category: .session)\n        for payload in payloads {\n            do {\n                let encrypted = try noiseService.encrypt(payload, for: peerID)\n                let packet = BitchatPacket(\n                    type: MessageType.noiseEncrypted.rawValue,\n                    senderID: myPeerIDData,\n                    recipientID: Data(hexString: peerID.id),\n                    timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                    payload: encrypted,\n                    signature: nil,\n                    ttl: messageTTL\n                )\n                broadcastPacket(packet)\n            } catch {\n                SecureLogger.error(\"❌ Failed to send pending noise payload to \\(peerID): \\(error)\")\n            }\n        }\n    }\n    \n    private func updatePeerLastSeen(_ peerID: PeerID) {\n        // Use async to avoid deadlock - we don't need immediate consistency for last seen updates\n        collectionsQueue.async(flags: .barrier) {\n            if var peer = self.peers[peerID] {\n                peer.lastSeen = Date()\n                self.peers[peerID] = peer\n            }\n        }\n    }\n\n    // Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window\n    private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {\n        let now = Date()\n        let last = recentDisconnectNotifies[peerID]\n        if last == nil || now.timeIntervalSince(last!) >= TransportConfig.bleDisconnectNotifyDebounceSeconds {\n            delegate?.didDisconnectFromPeer(peerID)\n            recentDisconnectNotifies[peerID] = now\n        } else {\n            // Suppressed duplicate disconnect notification\n        }\n    }\n    \n    // NEW: Publish peer snapshots to subscribers and notify Transport delegates\n    private func publishFullPeerData() {\n        let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {\n            // Compute nickname collision counts for connected peers\n            let connected = peers.values.filter { $0.isConnected }\n            var counts: [String: Int] = [:]\n            for p in connected { counts[p.nickname, default: 0] += 1 }\n            counts[myNickname, default: 0] += 1\n            return peers.values.map { info in\n                var display = info.nickname\n                if info.isConnected, (counts[info.nickname] ?? 0) > 1 {\n                    display += \"#\" + String(info.peerID.id.prefix(4))\n                }\n                return TransportPeerSnapshot(\n                    peerID: info.peerID,\n                    nickname: display,\n                    isConnected: info.isConnected,\n                    noisePublicKey: info.noisePublicKey,\n                    lastSeen: info.lastSeen\n                )\n            }\n        }\n        // Notify non-UI listeners\n        peerSnapshotSubject.send(transportPeers)\n        // Notify UI on MainActor via delegate\n        Task { @MainActor [weak self] in\n            self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)\n        }\n    }\n    \n    // MARK: Consolidated Maintenance\n    \n    private func performMaintenance() {\n        maintenanceCounter += 1\n        \n        // Adaptive announce: reduce frequency when we have connected peers\n        let now = Date()\n        let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }\n        let elapsed = now.timeIntervalSince(lastAnnounceSent)\n        if connectedCount == 0 {\n            // Discovery mode: keep frequent announces\n            if elapsed >= TransportConfig.bleAnnounceIntervalSeconds { sendAnnounce(forceSend: true) }\n        } else {\n            // Connected mode: announce less often; much less in dense networks\n            let base = connectedCount >= TransportConfig.bleHighDegreeThreshold ?\n                TransportConfig.bleConnectedAnnounceBaseSecondsDense : TransportConfig.bleConnectedAnnounceBaseSecondsSparse\n            let jitter = connectedCount >= TransportConfig.bleHighDegreeThreshold ?\n                TransportConfig.bleConnectedAnnounceJitterDense : TransportConfig.bleConnectedAnnounceJitterSparse\n            let target = base + Double.random(in: -jitter...jitter)\n            if elapsed >= target { sendAnnounce(forceSend: true) }\n        }\n\n        // Activity-driven quick-announce: if we've seen any packet in last 5s and it has\n        // been >=10s since the last announce, send a presence nudge.\n        let recentSeen = collectionsQueue.sync { () -> Bool in\n            let cutoff = now.addingTimeInterval(-5.0)\n            return recentPacketTimestamps.contains(where: { $0 >= cutoff })\n        }\n        if recentSeen && elapsed >= 10.0 {\n            sendAnnounce(forceSend: true)\n        }\n        \n        // If we have no peers, ensure we're scanning and advertising\n        if peers.isEmpty {\n            // Ensure we're advertising as peripheral\n            if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising {\n                pm.startAdvertising(buildAdvertisementData())\n            }\n        }\n        \n        // Update scanning duty-cycle based on connectivity\n        updateScanningDutyCycle(connectedCount: connectedCount)\n        updateRSSIThreshold(connectedCount: connectedCount)\n        \n        // Check peer connectivity every cycle for snappier UI updates\n        checkPeerConnectivity()\n        \n        // Every 30 seconds (3 cycles): Cleanup\n        if maintenanceCounter % 3 == 0 {\n            performCleanup()\n        }\n\n        // Attempt to flush any spooled directed messages periodically (~every 5 seconds)\n        if maintenanceCounter % 2 == 1 {\n            flushDirectedSpool()\n        }\n\n        // Periodically attempt to drain pending notifications and writes as backup\n        // in case callbacks are missed or delayed (every maintenance cycle = 5 seconds)\n        drainPendingNotificationsIfPossible()\n        drainAllPendingWrites()\n\n        // No rotating alias: nothing to refresh\n        \n        // Reset counter to prevent overflow (every 60 seconds)\n        if maintenanceCounter >= 6 {\n            maintenanceCounter = 0\n        }\n    }\n    \n    private func checkPeerConnectivity() {\n        let now = Date()\n        var disconnectedPeers: [PeerID] = []\n        let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { Array(peers.keys) }\n        var cachedLinkStates: [PeerID: (hasPeripheral: Bool, hasCentral: Bool)] = [:]\n        for peerID in peerIDsForLinkState {\n            cachedLinkStates[peerID] = linkState(for: peerID)\n        }\n        \n        var removedOfflineCount = 0\n        collectionsQueue.sync(flags: .barrier) {\n            for (peerID, peer) in peers {\n                let age = now.timeIntervalSince(peer.lastSeen)\n                let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds\n                if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {\n                    // Check if we still have an active BLE connection to this peer\n                    let state = cachedLinkStates[peerID] ?? (hasPeripheral: false, hasCentral: false)\n                    let hasPeripheralConnection = state.hasPeripheral\n                    let hasCentralConnection = state.hasCentral\n                    \n                    // If direct link is gone, mark as not connected (retain entry for reachability)\n                    if !hasPeripheralConnection && !hasCentralConnection {\n                        var updated = peer\n                        updated.isConnected = false\n                        peers[peerID] = updated\n                        disconnectedPeers.append(peerID)\n                    }\n                }\n                // Cleanup: remove peers that are not connected and past reachability retention\n                if !peer.isConnected {\n                    if age > retention {\n                        SecureLogger.debug(\"🗑️ Removing stale peer after reachability window: \\(peerID) (\\(peer.nickname))\", category: .session)\n                        // Also remove any stored announcement from sync candidates\n                        gossipSyncManager?.removeAnnouncementForPeer(peerID)\n                        peers.removeValue(forKey: peerID)\n                        removedOfflineCount += 1\n                    }\n                }\n            }\n        }\n        \n        // Update UI if there were direct disconnections or offline removals\n        if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {\n            notifyUI { [weak self] in\n                guard let self else { return }\n                \n                // Get current peer list (after removal)\n                let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }\n                \n                for peerID in disconnectedPeers {\n                    self.delegate?.didDisconnectFromPeer(peerID)\n                }\n                // Publish snapshots so UnifiedPeerService updates connection/reachability icons\n                self.requestPeerDataPublish()\n                self.delegate?.didUpdatePeerList(currentPeerIDs)\n            }\n        }\n        \n        // Refresh local topology to keep our own entry fresh and sync any changes\n        refreshLocalTopology()\n        // Prune stale topology nodes (using safe retention window)\n        meshTopology.prune(olderThan: 60.0)\n    }\n    \n    private func performCleanup() {\n        let now = Date()\n        \n        // Clean old processed messages efficiently\n        messageDeduplicator.cleanup()\n        \n        // Clean old fragments (> configured seconds old)\n        collectionsQueue.sync(flags: .barrier) {\n            let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds)\n            let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key }\n            for fragmentID in oldFragments {\n                incomingFragments.removeValue(forKey: fragmentID)\n                fragmentMetadata.removeValue(forKey: fragmentID)\n            }\n        }\n\n        // Clean old connection timeout backoff entries (> window)\n        let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds)\n        recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff }\n\n        // Clean up stale scheduled relays that somehow persisted (> 2s)\n        collectionsQueue.async(flags: .barrier) { [weak self] in\n            guard let self = self else { return }\n            if !self.scheduledRelays.isEmpty {\n                // Nothing to compare times to; just cap the size defensively\n                if self.scheduledRelays.count > 512 {\n                    self.scheduledRelays.removeAll()\n                }\n            }\n        }\n\n        // Clean ingress link records older than configured seconds\n        collectionsQueue.async(flags: .barrier) { [weak self] in\n            guard let self = self else { return }\n            let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds)\n            if !self.ingressByMessageID.isEmpty {\n                self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }\n            }\n            // Clean expired directed spooled items\n            if !self.pendingDirectedRelays.isEmpty {\n                var cleaned: [PeerID: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]\n                for (recipient, dict) in self.pendingDirectedRelays {\n                    let pruned = dict.filter { now.timeIntervalSince($0.value.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds }\n                    if !pruned.isEmpty { cleaned[recipient] = pruned }\n                }\n                self.pendingDirectedRelays = cleaned\n            }\n        }\n\n        messageQueue.async(flags: .barrier) { [weak self] in\n            guard let self = self else { return }\n            guard !self.selfBroadcastMessageIDs.isEmpty else { return }\n            let cutoff = now.addingTimeInterval(-TransportConfig.messageDedupMaxAgeSeconds)\n            self.selfBroadcastMessageIDs = self.selfBroadcastMessageIDs.filter { cutoff <= $0.value.timestamp }\n        }\n    }\n\n    private func updateScanningDutyCycle(connectedCount: Int) {\n        guard let central = centralManager, central.state == .poweredOn else { return }\n        // Duty cycle only when app is active and at least one peer connected\n        #if os(iOS)\n        let active = isAppActive\n        #else\n        let active = true\n        #endif\n        // Force full-time scanning if we have very few neighbors or very recent traffic\n        let hasRecentTraffic: Bool = collectionsQueue.sync {\n            let cutoff = Date().addingTimeInterval(-TransportConfig.bleRecentTrafficForceScanSeconds)\n            return recentPacketTimestamps.contains(where: { $0 >= cutoff })\n        }\n        let forceScanOn = (connectedCount <= 2) || hasRecentTraffic\n        let shouldDuty = dutyEnabled && active && connectedCount > 0 && !forceScanOn\n        if shouldDuty {\n            if scanDutyTimer == nil {\n                // Start timer to toggle scanning on/off\n                let t = DispatchSource.makeTimerSource(queue: bleQueue)\n                // Start with scanning ON; we'll turn OFF after onDuration\n                if !central.isScanning { startScanning() }\n                dutyActive = true\n                // Adjust duty cycle under dense networks to save battery\n                if connectedCount >= TransportConfig.bleHighDegreeThreshold {\n                    dutyOnDuration = TransportConfig.bleDutyOnDurationDense\n                    dutyOffDuration = TransportConfig.bleDutyOffDurationDense\n                } else {\n                    dutyOnDuration = TransportConfig.bleDutyOnDuration\n                    dutyOffDuration = TransportConfig.bleDutyOffDuration\n                }\n                t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)\n                t.setEventHandler { [weak self] in\n                    guard let self = self, let c = self.centralManager else { return }\n                    if self.dutyActive {\n                        // Turn OFF scanning for offDuration\n                        if c.isScanning { c.stopScan() }\n                        self.dutyActive = false\n                        // Schedule turning back ON after offDuration\n                        self.bleQueue.asyncAfter(deadline: .now() + self.dutyOffDuration) {\n                            if self.centralManager?.state == .poweredOn { self.startScanning() }\n                            self.dutyActive = true\n                        }\n                    }\n                }\n                t.resume()\n                scanDutyTimer = t\n            }\n        } else {\n            // Cancel duty cycle and ensure scanning is ON for discovery\n            scanDutyTimer?.cancel()\n            scanDutyTimer = nil\n            if !central.isScanning { startScanning() }\n        }\n    }\n\n    private func updateRSSIThreshold(connectedCount: Int) {\n        // Adjust RSSI threshold based on connectivity, candidate pressure, and failures\n        if connectedCount == 0 {\n            // Isolated: relax floor slowly to hunt for distant nodes\n            if lastIsolatedAt == nil { lastIsolatedAt = Date() }\n            let iso = lastIsolatedAt ?? Date()\n            let elapsed = Date().timeIntervalSince(iso)\n            if elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds {\n                dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedRelaxed\n            } else {\n                dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedBase\n            }\n            return\n        }\n        lastIsolatedAt = nil\n        // Base threshold when connected\n        var threshold = TransportConfig.bleDynamicRSSIThresholdDefault\n        // If we're at budget or queue is large, prefer closer peers\n        let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count\n        if linkCount >= maxCentralLinks || connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax {\n            threshold = TransportConfig.bleRSSIConnectedThreshold\n        }\n        // If we have many recent timeouts, raise further\n        let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < TransportConfig.bleRecentTimeoutWindowSeconds }.count\n        if recentTimeouts >= TransportConfig.bleRecentTimeoutCountThreshold {\n            threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)\n        }\n        dynamicRSSIThreshold = threshold\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/BLE/MimeType.swift",
    "content": "//\n// MimeType.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport UniformTypeIdentifiers\n\n// MARK: - Extensions for missing UTTypes\n\nextension UTType {\n    static let webP = UTType(importedAs: \"image/webp\")\n    static let aac  = UTType(importedAs: \"audio/aac\")\n    static let m4a  = UTType(importedAs: \"audio/m4a\")\n    static let ogg  = UTType(importedAs: \"audio/ogg\")\n}\n\n// MARK: - MimeType Enum\n\nenum MimeType: CaseIterable, Hashable {\n    case jpeg\n    case jpg\n    case png\n    case gif\n    case webp\n    case mp4Audio\n    case m4a\n    case aac\n    case mpeg\n    case mp3\n    case wav\n    case xWav\n    case ogg\n    case pdf\n    case octetStream\n\n    var utType: UTType {\n        switch self {\n        case .jpeg, .jpg:   .jpeg\n        case .png:          .png\n        case .gif:          .gif\n        case .webp:         .webP\n        case .aac:          .aac\n        case .m4a:          .m4a\n        case .mp4Audio:     .mpeg4Audio\n        case .mp3, .mpeg:   .mp3\n        case .wav, .xWav:   .wav\n        case .ogg:          .ogg\n        case .pdf:          .pdf\n        case .octetStream:  .data\n        }\n    }\n    \n    var category: Category {\n        switch self {\n        case .jpeg, .jpg, .png, .gif, .webp:\n            return .image\n        case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:\n            return .audio\n        case .pdf, .octetStream:\n            return .file\n        }\n    }\n\n\n    var mimeString: String {\n        switch self {\n        case .jpeg, .jpg:   \"image/jpeg\"\n        case .png:          \"image/png\"\n        case .gif:          \"image/gif\"\n        case .webp:         \"image/webp\"\n        case .mp4Audio:     \"audio/mp4\"\n        case .m4a:          \"audio/m4a\"\n        case .aac:          \"audio/aac\"\n        case .mpeg:         \"audio/mpeg\"\n        case .mp3:          \"audio/mp3\"\n        case .wav:          \"audio/wav\"\n        case .xWav:         \"audio/x-wav\"\n        case .ogg:          \"audio/ogg\"\n        case .pdf:          \"application/pdf\"\n        case .octetStream:  \"application/octet-stream\"\n        }\n    }\n    \n    var defaultExtension: String {\n        switch self {\n        case .jpeg, .jpg:           \"jpg\"\n        case .png:                  \"png\"\n        case .webp:                 \"webp\"\n        case .gif:                  \"gif\"\n        case .mp4Audio, .m4a, .aac: \"m4a\"\n        case .mpeg, .mp3:           \"mp3\"\n        case .wav, .xWav:           \"wav\"\n        case .ogg:                  \"ogg\"\n        case .pdf:                  \"pdf\"\n        case .octetStream:          \"bin\"\n        }\n    }\n\n    static var allowed: Set<MimeType> = [\n        .jpeg, .jpg, .png, .gif, .webp,\n        .mp4Audio, .m4a, .aac, .mpeg, .mp3,\n        .wav, .xWav, .ogg,\n        .pdf, .octetStream\n    ]\n\n    var isAllowed: Bool {\n        Self.allowed.contains(self)\n    }\n\n    // MARK: - Byte signature validation\n    func matches(data: Data) -> Bool {\n        guard !data.isEmpty else { return false }\n\n        // Generic type → skip validation\n        if self == .octetStream { return true }\n\n        switch self {\n        case .jpeg, .jpg:\n            return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF\n\n        case .png:\n            return data.count >= 8 &&\n                   data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&\n                   data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A\n\n        case .gif:\n            return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&\n                   data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61\n\n        case .webp:\n            return data.count >= 12 &&\n                   data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&\n                   data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50\n\n        case .m4a, .mp4Audio, .aac:\n            // AVAudioRecorder output varies by platform - be lenient\n            // Security: size already capped + sandboxed execution\n            return data.count > 100\n\n        case .mpeg, .mp3:\n            if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {\n                return true // ID3 header\n            }\n            return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0\n\n        case .wav, .xWav:\n            return data.count >= 12 &&\n                   data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&\n                   data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45\n\n        case .ogg:\n            return data.count >= 4 &&\n                   data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53\n\n        case .pdf:\n            return data.count >= 4 &&\n                   data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46\n\n        default:\n            return false\n        }\n    }\n\n    // MARK: - Convenience Initializers\n\n    init?(_ mimeString: String?) {\n        guard let mimeString else { return nil }\n        \n        let normalized = mimeString.lowercased()\n\n        // Direct match with our canonical list\n        if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {\n            self = match\n            return\n        }\n\n        // Let UTType normalize aliases like \"image/jpg\", \"audio/x-wav\", etc.\n        if let type = UTType(mimeType: normalized),\n           let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {\n            self = match\n            return\n        }\n\n        return nil\n    }\n}\n\nextension MimeType {\n    enum Category: String {\n        case audio, image, file\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/CommandProcessor.swift",
    "content": "//\n// CommandProcessor.swift\n// bitchat\n//\n// Handles command parsing and execution for BitChat\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Foundation\n\n/// Result of command processing\nenum CommandResult {\n    case success(message: String?)\n    case error(message: String)\n    case handled  // Command handled, no message needed\n}\n\n/// Simple struct for geo participant info used by CommandProcessor\nstruct CommandGeoParticipant {\n    let id: String        // pubkey hex (lowercased)\n    let displayName: String\n}\n\n/// Protocol defining what CommandProcessor needs from its context.\n/// This breaks the circular dependency between CommandProcessor and ChatViewModel.\n@MainActor\nprotocol CommandContextProvider: AnyObject {\n    // MARK: - State Properties\n    var nickname: String { get }\n    var selectedPrivateChatPeer: PeerID? { get }\n    var blockedUsers: Set<String> { get }\n    var privateChats: [PeerID: [BitchatMessage]] { get set }\n    var idBridge: NostrIdentityBridge { get }\n\n    // MARK: - Peer Lookup\n    func getPeerIDForNickname(_ nickname: String) -> PeerID?\n    func getVisibleGeoParticipants() -> [CommandGeoParticipant]\n    func nostrPubkeyForDisplayName(_ displayName: String) -> String?\n\n    // MARK: - Chat Actions\n    func startPrivateChat(with peerID: PeerID)\n    func sendPrivateMessage(_ content: String, to peerID: PeerID)\n    func clearCurrentPublicTimeline()\n    func sendPublicRaw(_ content: String)\n\n    // MARK: - System Messages\n    func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)\n    func addPublicSystemMessage(_ content: String)\n\n    // MARK: - Favorites\n    func toggleFavorite(peerID: PeerID)\n    func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)\n}\n\n/// Processes chat commands in a focused, efficient way\n@MainActor\nfinal class CommandProcessor {\n    weak var contextProvider: CommandContextProvider?\n    weak var meshService: Transport?\n    private let identityManager: SecureIdentityStateManagerProtocol\n\n    init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {\n        self.contextProvider = contextProvider\n        self.meshService = meshService\n        self.identityManager = identityManager\n    }\n    \n    /// Process a command string\n    @MainActor\n    func process(_ command: String) -> CommandResult {\n        let parts = command.split(separator: \" \", maxSplits: 1, omittingEmptySubsequences: false)\n        guard let cmd = parts.first else { return .error(message: \"Invalid command\") }\n        let args = parts.count > 1 ? String(parts[1]) : \"\"\n        \n        // Geohash context: disable favoriting in public geohash or GeoDM\n        let inGeoPublic: Bool = {\n            switch LocationChannelManager.shared.selectedChannel {\n            case .mesh: return false\n            case .location: return true\n            }\n        }()\n        let inGeoDM = contextProvider?.selectedPrivateChatPeer?.isGeoDM == true\n\n        switch cmd {\n        case \"/m\", \"/msg\":\n            return handleMessage(args)\n        case \"/w\", \"/who\":\n            return handleWho()\n        case \"/clear\":\n            return handleClear()\n        case \"/hug\":\n            return handleEmote(args, command: \"hug\", action: \"hugs\", emoji: \"🫂\")\n        case \"/slap\":\n            return handleEmote(args, command: \"slap\", action: \"slaps\", emoji: \"🐟\", suffix: \" around a bit with a large trout\")\n        case \"/block\":\n            return handleBlock(args)\n        case \"/unblock\":\n            return handleUnblock(args)\n        case \"/fav\":\n            if inGeoPublic || inGeoDM { return .error(message: \"favorites are only for mesh peers in #mesh\") }\n            return handleFavorite(args, add: true)\n        case \"/unfav\":\n            if inGeoPublic || inGeoDM { return .error(message: \"favorites are only for mesh peers in #mesh\") }\n            return handleFavorite(args, add: false)\n        default:\n            return .error(message: \"unknown command: \\(cmd)\")\n        }\n    }\n\n    // MARK: - Command Handlers\n    \n    private func handleMessage(_ args: String) -> CommandResult {\n        let parts = args.split(separator: \" \", maxSplits: 1, omittingEmptySubsequences: false)\n        guard !parts.isEmpty else {\n            return .error(message: \"usage: /msg @nickname [message]\")\n        }\n        \n        let targetName = String(parts[0])\n        let nickname = targetName.hasPrefix(\"@\") ? String(targetName.dropFirst()) : targetName\n        \n        guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {\n            return .error(message: \"'\\(nickname)' not found\")\n        }\n\n        contextProvider?.startPrivateChat(with: peerID)\n\n        if parts.count > 1 {\n            let message = String(parts[1])\n            contextProvider?.sendPrivateMessage(message, to: peerID)\n        }\n        \n        return .success(message: \"started private chat with \\(nickname)\")\n    }\n    \n    private func handleWho() -> CommandResult {\n        // Show geohash participants when in a geohash channel; otherwise mesh peers\n        switch LocationChannelManager.shared.selectedChannel {\n        case .location(let ch):\n            // Geohash context: show visible geohash participants (exclude self)\n            guard let vm = contextProvider else { return .success(message: \"nobody around\") }\n            let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()\n            let people = vm.getVisibleGeoParticipants().filter { person in\n                if let me = myHex { return person.id.lowercased() != me }\n                return true\n            }\n            let names = people.map { $0.displayName }\n            if names.isEmpty { return .success(message: \"no one else is online right now\") }\n            return .success(message: \"online: \" + names.sorted().joined(separator: \", \"))\n        case .mesh:\n            // Mesh context: show connected peer nicknames\n            guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {\n                return .success(message: \"no one else is online right now\")\n            }\n            let onlineList = peers.values.sorted().joined(separator: \", \")\n            return .success(message: \"online: \\(onlineList)\")\n        }\n    }\n    \n    private func handleClear() -> CommandResult {\n        if let peerID = contextProvider?.selectedPrivateChatPeer {\n            contextProvider?.privateChats[peerID]?.removeAll()\n        } else {\n            contextProvider?.clearCurrentPublicTimeline()\n        }\n        return .handled\n    }\n    \n    private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = \"\") -> CommandResult {\n        let targetName = args.trimmingCharacters(in: .whitespaces)\n        guard !targetName.isEmpty else {\n            return .error(message: \"usage: /\\(command) <nickname>\")\n        }\n        \n        let nickname = targetName.hasPrefix(\"@\") ? String(targetName.dropFirst()) : targetName\n        \n        guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname),\n              let myNickname = contextProvider?.nickname else {\n            return .error(message: \"cannot \\(command) \\(nickname): not found\")\n        }\n        \n        let emoteContent = \"* \\(emoji) \\(myNickname) \\(action) \\(nickname)\\(suffix) *\"\n        \n        if contextProvider?.selectedPrivateChatPeer != nil {\n            // In private chat\n            if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {\n                let personalMessage = \"* \\(emoji) \\(myNickname) \\(action) you\\(suffix) *\"\n                meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,\n                                               recipientNickname: peerNickname,\n                                               messageID: UUID().uuidString)\n                // Also add a local system message so the sender sees a natural-language confirmation\n                let pastAction: String = {\n                    switch action {\n                    case \"hugs\": return \"hugged\"\n                    case \"slaps\": return \"slapped\"\n                    default: return action.hasSuffix(\"e\") ? action + \"d\" : action + \"ed\"\n                    }\n                }()\n                let localText = \"\\(emoji) you \\(pastAction) \\(nickname)\\(suffix)\"\n                contextProvider?.addLocalPrivateSystemMessage(localText, to: targetPeerID)\n            }\n        } else {\n            // In public chat: send to active public channel (mesh or geohash)\n            contextProvider?.sendPublicRaw(emoteContent)\n            let publicEcho = \"\\(emoji) \\(myNickname) \\(action) \\(nickname)\\(suffix)\"\n            contextProvider?.addPublicSystemMessage(publicEcho)\n        }\n        \n        return .handled\n    }\n    \n    private func handleBlock(_ args: String) -> CommandResult {\n        let targetName = args.trimmingCharacters(in: .whitespaces)\n        \n        if targetName.isEmpty {\n            // List blocked users (mesh) and geohash (Nostr) blocks\n            let meshBlocked = contextProvider?.blockedUsers ?? []\n            var blockedNicknames: [String] = []\n            if let peers = meshService?.getPeerNicknames() {\n                for (peerID, nickname) in peers {\n                    if let fingerprint = meshService?.getFingerprint(for: peerID),\n                       meshBlocked.contains(fingerprint) {\n                        blockedNicknames.append(nickname)\n                    }\n                }\n            }\n\n            // Geohash blocked names (prefer visible display names; fallback to #suffix)\n            let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())\n            var geoNames: [String] = []\n            if let vm = contextProvider {\n                let visible = vm.getVisibleGeoParticipants()\n                let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })\n                for pk in geoBlocked {\n                    if let name = visibleIndex[pk.lowercased()] {\n                        geoNames.append(name)\n                    } else {\n                        let suffix = String(pk.suffix(4))\n                        geoNames.append(\"anon#\\(suffix)\")\n                    }\n                }\n            }\n\n            let meshList = blockedNicknames.isEmpty ? \"none\" : blockedNicknames.sorted().joined(separator: \", \")\n            let geoList = geoNames.isEmpty ? \"none\" : geoNames.sorted().joined(separator: \", \")\n            return .success(message: \"blocked peers: \\(meshList) | geohash blocks: \\(geoList)\")\n        }\n        \n        let nickname = targetName.hasPrefix(\"@\") ? String(targetName.dropFirst()) : targetName\n        \n        if let peerID = contextProvider?.getPeerIDForNickname(nickname),\n           let fingerprint = meshService?.getFingerprint(for: peerID) {\n            if identityManager.isBlocked(fingerprint: fingerprint) {\n                return .success(message: \"\\(nickname) is already blocked\")\n            }\n            // Block the user (mesh/noise identity)\n            if var identity = identityManager.getSocialIdentity(for: fingerprint) {\n                identity.isBlocked = true\n                identity.isFavorite = false\n                identityManager.updateSocialIdentity(identity)\n            } else {\n                let blockedIdentity = SocialIdentity(\n                    fingerprint: fingerprint,\n                    localPetname: nil,\n                    claimedNickname: nickname,\n                    trustLevel: .unknown,\n                    isFavorite: false,\n                    isBlocked: true,\n                    notes: nil\n                )\n                identityManager.updateSocialIdentity(blockedIdentity)\n            }\n            return .success(message: \"blocked \\(nickname). you will no longer receive messages from them\")\n        }\n        // Mesh lookup failed; try geohash (Nostr) participant by display name\n        if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {\n            if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {\n                return .success(message: \"\\(nickname) is already blocked\")\n            }\n            identityManager.setNostrBlocked(pub, isBlocked: true)\n            return .success(message: \"blocked \\(nickname) in geohash chats\")\n        }\n        \n        return .error(message: \"cannot block \\(nickname): not found or unable to verify identity\")\n    }\n    \n    private func handleUnblock(_ args: String) -> CommandResult {\n        let targetName = args.trimmingCharacters(in: .whitespaces)\n        guard !targetName.isEmpty else {\n            return .error(message: \"usage: /unblock <nickname>\")\n        }\n        \n        let nickname = targetName.hasPrefix(\"@\") ? String(targetName.dropFirst()) : targetName\n        \n        if let peerID = contextProvider?.getPeerIDForNickname(nickname),\n           let fingerprint = meshService?.getFingerprint(for: peerID) {\n            if !identityManager.isBlocked(fingerprint: fingerprint) {\n                return .success(message: \"\\(nickname) is not blocked\")\n            }\n            identityManager.setBlocked(fingerprint, isBlocked: false)\n            return .success(message: \"unblocked \\(nickname)\")\n        }\n        // Try geohash unblock\n        if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {\n            if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {\n                return .success(message: \"\\(nickname) is not blocked\")\n            }\n            identityManager.setNostrBlocked(pub, isBlocked: false)\n            return .success(message: \"unblocked \\(nickname) in geohash chats\")\n        }\n        return .error(message: \"cannot unblock \\(nickname): not found\")\n    }\n    \n    private func handleFavorite(_ args: String, add: Bool) -> CommandResult {\n        let targetName = args.trimmingCharacters(in: .whitespaces)\n        guard !targetName.isEmpty else {\n            return .error(message: \"usage: /\\(add ? \"fav\" : \"unfav\") <nickname>\")\n        }\n        \n        let nickname = targetName.hasPrefix(\"@\") ? String(targetName.dropFirst()) : targetName\n        \n        guard let peerID = contextProvider?.getPeerIDForNickname(nickname),\n              let noisePublicKey = Data(hexString: peerID.id) else {\n            return .error(message: \"can't find peer: \\(nickname)\")\n        }\n        \n        if add {\n            let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)\n            FavoritesPersistenceService.shared.addFavorite(\n                peerNoisePublicKey: noisePublicKey,\n                peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,\n                peerNickname: nickname\n            )\n            \n            contextProvider?.toggleFavorite(peerID: peerID)\n            contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)\n            \n            return .success(message: \"added \\(nickname) to favorites\")\n        } else {\n            FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)\n            \n            contextProvider?.toggleFavorite(peerID: peerID)\n            contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)\n            \n            return .success(message: \"removed \\(nickname) from favorites\")\n        }\n    }\n    \n}\n"
  },
  {
    "path": "bitchat/Services/FavoritesPersistenceService.swift",
    "content": "import BitLogger\nimport Foundation\nimport Combine\n\n/// Manages persistent favorite relationships between peers\n@MainActor\nfinal class FavoritesPersistenceService: ObservableObject {\n    \n    struct FavoriteRelationship: Codable {\n        let peerNoisePublicKey: Data\n        let peerNostrPublicKey: String?\n        let peerNickname: String\n        let isFavorite: Bool\n        let theyFavoritedUs: Bool\n        let favoritedAt: Date\n        let lastUpdated: Date\n        // Track what we last sent as OUR npub to this peer, to avoid resending unless it changes\n        // Note: we do not track which npub we last sent to them; sending happens only on favorite toggle\n        \n        var isMutual: Bool {\n            isFavorite && theyFavoritedUs\n        }\n    }\n    \n    // We intentionally do not track when we last sent our npub; sending happens only on favorite toggle.\n\n    private static let storageKey = \"chat.bitchat.favorites\"\n    private static let keychainService = \"chat.bitchat.favorites\"\n    private let keychain: KeychainManagerProtocol\n    \n    @Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship\n    @Published private(set) var mutualFavorites: Set<Data> = []\n    \n    static let shared = FavoritesPersistenceService()\n\n    init(keychain: KeychainManagerProtocol = KeychainManager()) {\n        self.keychain = keychain\n        loadFavorites()\n        \n        // Update mutual favorites when favorites change\n        $favorites\n            .map { favorites in\n                Set(favorites.compactMap { $0.value.isMutual ? $0.key : nil })\n            }\n            .assign(to: &$mutualFavorites)\n    }\n    \n    /// Add or update a favorite\n    func addFavorite(\n        peerNoisePublicKey: Data,\n        peerNostrPublicKey: String? = nil,\n        peerNickname: String\n    ) {\n        SecureLogger.info(\"⭐️ Adding favorite: \\(peerNickname) (\\(peerNoisePublicKey.hexEncodedString()))\", category: .session)\n        \n        let existing = favorites[peerNoisePublicKey]\n        \n        let relationship = FavoriteRelationship(\n            peerNoisePublicKey: peerNoisePublicKey,\n            peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey,\n            peerNickname: peerNickname,\n            isFavorite: true,\n            theyFavoritedUs: existing?.theyFavoritedUs ?? false,\n            favoritedAt: existing?.favoritedAt ?? Date(),\n            lastUpdated: Date()\n        )\n        \n        // Log if this creates a mutual favorite\n        if relationship.isMutual {\n            SecureLogger.info(\"💕 Mutual favorite relationship established with \\(peerNickname)!\", category: .session)\n        }\n        \n        favorites[peerNoisePublicKey] = relationship\n        saveFavorites()\n        \n        // Notify observers\n        NotificationCenter.default.post(\n            name: .favoriteStatusChanged,\n            object: nil,\n            userInfo: [\"peerPublicKey\": peerNoisePublicKey]\n        )\n    }\n    \n    /// Remove a favorite\n    func removeFavorite(peerNoisePublicKey: Data) {\n        guard let existing = favorites[peerNoisePublicKey] else { return }\n        \n        SecureLogger.info(\"⭐️ Removing favorite: \\(existing.peerNickname) (\\(peerNoisePublicKey.hexEncodedString()))\", category: .session)\n        \n        // If they still favorite us, keep the record but mark us as not favoriting\n        if existing.theyFavoritedUs {\n            let updated = FavoriteRelationship(\n                peerNoisePublicKey: existing.peerNoisePublicKey,\n                peerNostrPublicKey: existing.peerNostrPublicKey,\n                peerNickname: existing.peerNickname,\n                isFavorite: false,\n                theyFavoritedUs: true,\n                favoritedAt: existing.favoritedAt,\n                lastUpdated: Date()\n            )\n            favorites[peerNoisePublicKey] = updated\n            // Keeping record - they still favorite us\n        } else {\n            // Neither side favorites, remove completely\n            favorites.removeValue(forKey: peerNoisePublicKey)\n            // Completely removed from favorites\n        }\n        \n        saveFavorites()\n        \n        // Notify observers\n        NotificationCenter.default.post(\n            name: .favoriteStatusChanged,\n            object: nil,\n            userInfo: [\"peerPublicKey\": peerNoisePublicKey]\n        )\n    }\n    \n    /// Update when we learn a peer favorited/unfavorited us\n    func updatePeerFavoritedUs(\n        peerNoisePublicKey: Data,\n        favorited: Bool,\n        peerNickname: String? = nil,\n        peerNostrPublicKey: String? = nil\n    ) {\n        let existing = favorites[peerNoisePublicKey]\n        let displayName = peerNickname ?? existing?.peerNickname ?? \"Unknown\"\n        \n        SecureLogger.info(\"📨 Received favorite notification: \\(displayName) \\(favorited ? \"favorited\" : \"unfavorited\") us\", category: .session)\n        \n        let relationship = FavoriteRelationship(\n            peerNoisePublicKey: peerNoisePublicKey,\n            peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey,\n            peerNickname: displayName,\n            isFavorite: existing?.isFavorite ?? false,\n            theyFavoritedUs: favorited,\n            favoritedAt: existing?.favoritedAt ?? Date(),\n            lastUpdated: Date()\n        )\n        \n        if !relationship.isFavorite && !relationship.theyFavoritedUs {\n            // Neither side favorites, remove completely\n            favorites.removeValue(forKey: peerNoisePublicKey)\n            // Removed - neither side favorites anymore\n        } else {\n            favorites[peerNoisePublicKey] = relationship\n            \n            // Check if this creates a mutual favorite\n            if relationship.isMutual {\n                SecureLogger.info(\"💕 Mutual favorite relationship established with \\(displayName)!\", category: .session)\n            }\n        }\n        \n        saveFavorites()\n        \n        // Notify observers\n        NotificationCenter.default.post(\n            name: .favoriteStatusChanged,\n            object: nil,\n            userInfo: [\"peerPublicKey\": peerNoisePublicKey]\n        )\n    }\n    \n    /// Check if a peer is favorited by us\n    func isFavorite(_ peerNoisePublicKey: Data) -> Bool {\n        favorites[peerNoisePublicKey]?.isFavorite ?? false\n    }\n    \n    /// Check if we have a mutual favorite relationship\n    func isMutualFavorite(_ peerNoisePublicKey: Data) -> Bool {\n        favorites[peerNoisePublicKey]?.isMutual ?? false\n    }\n    \n    /// Get favorite status for a peer\n    func getFavoriteStatus(for peerNoisePublicKey: Data) -> FavoriteRelationship? {\n        favorites[peerNoisePublicKey]\n    }\n\n    /// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)\n    /// Falls back to scanning favorites and matching on derived peer ID.\n    func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {\n        // Quick sanity: peerID should be 16 hex chars (8 bytes)\n        guard peerID.isShort else { return nil }\n        for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {\n            return rel\n        }\n        return nil\n    }\n    \n    /// Clear all favorites - used for panic mode\n    func clearAllFavorites() {\n        SecureLogger.warning(\"🧹 Clearing all favorites (panic mode)\", category: .session)\n        \n        favorites.removeAll()\n        saveFavorites()\n        \n        // Delete from keychain directly\n        keychain.delete(\n            key: Self.storageKey,\n            service: Self.keychainService\n        )\n        \n        // Post notification for UI update\n        NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil)\n    }\n    \n    // MARK: - Persistence\n    \n    private func saveFavorites() {\n        let relationships = Array(favorites.values)\n        // Saving favorite relationships to keychain\n        \n        do {\n            let encoder = JSONEncoder()\n            let data = try encoder.encode(relationships)\n            \n            // Store in keychain for security\n            keychain.save(\n                key: Self.storageKey,\n                data: data,\n                service: Self.keychainService,\n                accessible: nil\n            )\n            \n            // Successfully saved favorites\n        } catch {\n            SecureLogger.error(\"Failed to save favorites: \\(error)\", category: .session)\n        }\n    }\n    \n    private func loadFavorites() {\n        // Loading favorites from keychain\n        \n        guard let data = keychain.load(\n            key: Self.storageKey,\n            service: Self.keychainService\n        ) else { \n            return \n        }\n        \n        do {\n            let decoder = JSONDecoder()\n            let relationships = try decoder.decode([FavoriteRelationship].self, from: data)\n            \n            SecureLogger.info(\"✅ Loaded \\(relationships.count) favorite relationships\", category: .session)\n            \n            // Log Nostr public key info\n            for relationship in relationships {\n                if relationship.peerNostrPublicKey == nil {\n                    SecureLogger.warning(\"⚠️ No Nostr public key stored for '\\(relationship.peerNickname)'\", category: .session)\n                }\n            }\n            \n            // Convert to dictionary, cleaning up duplicates by public key (not nickname)\n            var seenPublicKeys: [Data: FavoriteRelationship] = [:]\n            var cleanedRelationships: [FavoriteRelationship] = []\n            \n            for relationship in relationships {\n                // Check for duplicates by public key (the actual unique identifier)\n                if let existing = seenPublicKeys[relationship.peerNoisePublicKey] {\n                    SecureLogger.warning(\"⚠️ Duplicate favorite found for public key \\(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\\(existing.peerNickname)' vs '\\(relationship.peerNickname)'\", category: .session)\n                    \n                    // Keep the most recent or most complete relationship\n                    if relationship.lastUpdated > existing.lastUpdated ||\n                       (relationship.peerNostrPublicKey != nil && existing.peerNostrPublicKey == nil) {\n                        // Replace with newer/more complete entry\n                        seenPublicKeys[relationship.peerNoisePublicKey] = relationship\n                        cleanedRelationships.removeAll { $0.peerNoisePublicKey == relationship.peerNoisePublicKey }\n                        cleanedRelationships.append(relationship)\n                    }\n                } else {\n                    seenPublicKeys[relationship.peerNoisePublicKey] = relationship\n                    cleanedRelationships.append(relationship)\n                }\n            }\n            \n            // If we cleaned up duplicates, save the cleaned list\n            if cleanedRelationships.count < relationships.count {\n                // Cleaned up duplicates\n                \n                // Clear and rebuild favorites dictionary\n                favorites.removeAll()\n                for relationship in cleanedRelationships {\n                    favorites[relationship.peerNoisePublicKey] = relationship\n                }\n                \n                // Save cleaned favorites\n                saveFavorites()\n                \n                // Notify that favorites have been cleaned up (synchronously since we're already on main actor)\n                NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil)\n            } else {\n                // No duplicates, just populate normally\n                for relationship in cleanedRelationships {\n                    favorites[relationship.peerNoisePublicKey] = relationship\n                }\n            }\n            \n            // Log loaded relationships\n            // Loaded relationships successfully\n        } catch {\n            SecureLogger.error(\"Failed to load favorites: \\(error)\", category: .session)\n        }\n    }\n}\n\n// MARK: - Notification Names\n\nextension Notification.Name {\n    static let favoriteStatusChanged = Notification.Name(\"FavoriteStatusChanged\")\n}\n"
  },
  {
    "path": "bitchat/Services/GeohashParticipantTracker.swift",
    "content": "//\n// GeohashParticipantTracker.swift\n// bitchat\n//\n// Tracks participants in geohash-based location channels.\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Foundation\n\n/// Represents a participant in a geohash channel\npublic struct GeoPerson: Identifiable, Equatable, Sendable {\n    public let id: String        // pubkey hex (lowercased)\n    public let displayName: String\n    public let lastSeen: Date\n\n    public init(id: String, displayName: String, lastSeen: Date) {\n        self.id = id\n        self.displayName = displayName\n        self.lastSeen = lastSeen\n    }\n}\n\n/// Protocol for resolving display names and checking block status\n@MainActor\npublic protocol GeohashParticipantContext: AnyObject {\n    /// Returns display name for a Nostr pubkey (e.g., \"alice#a1b2\" or \"anon#c3d4\")\n    func displayNameForPubkey(_ pubkeyHex: String) -> String\n    /// Returns true if the pubkey is blocked\n    func isBlocked(_ pubkeyHexLowercased: String) -> Bool\n}\n\n/// Tracks participants across multiple geohash channels\n@MainActor\npublic final class GeohashParticipantTracker: ObservableObject {\n\n    /// Activity cutoff duration (defaults to 5 minutes)\n    public let activityCutoff: TimeInterval\n\n    /// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]\n    private var participants: [String: [String: Date]] = [:]\n\n    /// Currently visible people for the active geohash\n    @Published public private(set) var visiblePeople: [GeoPerson] = []\n\n    /// The currently active geohash (if any)\n    private var activeGeohash: String?\n\n    /// Context for display name resolution and block checking\n    private weak var context: GeohashParticipantContext?\n\n    /// Timer for periodic refresh\n    private var refreshTimer: Timer?\n\n    public init(activityCutoff: TimeInterval = -300) { // default 5 minutes\n        self.activityCutoff = activityCutoff\n    }\n\n    /// Configure with a context provider\n    public func configure(context: GeohashParticipantContext) {\n        self.context = context\n    }\n\n    /// Set the currently active geohash\n    public func setActiveGeohash(_ geohash: String?) {\n        activeGeohash = geohash\n        if geohash == nil {\n            visiblePeople = []\n        } else {\n            refresh()\n        }\n    }\n\n    /// Record activity from a participant in the current active geohash\n    public func recordParticipant(pubkeyHex: String) {\n        guard let gh = activeGeohash else { return }\n        recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)\n    }\n\n    /// Record activity from a participant in a specific geohash\n    public func recordParticipant(pubkeyHex: String, geohash: String) {\n        let key = pubkeyHex.lowercased()\n        var map = participants[geohash] ?? [:]\n        map[key] = Date()\n        participants[geohash] = map\n        \n        // Always notify observers that state has changed so counts in UI update\n        objectWillChange.send()\n\n        // Only refresh visible list if this geohash is currently active\n        if activeGeohash == geohash {\n            refresh()\n        }\n    }\n\n    /// Remove a participant from all geohashes (used when blocking)\n    public func removeParticipant(pubkeyHex: String) {\n        let key = pubkeyHex.lowercased()\n        for (gh, var map) in participants {\n            map.removeValue(forKey: key)\n            participants[gh] = map\n        }\n        refresh()\n    }\n\n    /// Get participant count for a specific geohash\n    public func participantCount(for geohash: String) -> Int {\n        let cutoff = Date().addingTimeInterval(activityCutoff)\n        let map = participants[geohash] ?? [:]\n        return map.values.filter { $0 >= cutoff }.count\n    }\n\n    /// Get the visible people list for the active geohash (read-only query)\n    public func getVisiblePeople() -> [GeoPerson] {\n        guard let gh = activeGeohash, let context = context else { return [] }\n        let cutoff = Date().addingTimeInterval(activityCutoff)\n        let map = (participants[gh] ?? [:])\n            .filter { $0.value >= cutoff }\n            .filter { !context.isBlocked($0.key) }\n\n        return map\n            .map { (pub, seen) in\n                GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen)\n            }\n            .sorted { $0.lastSeen > $1.lastSeen }\n    }\n\n    /// Refresh the visible people list\n    public func refresh() {\n        visiblePeople = getVisiblePeople()\n    }\n\n    /// Start the periodic refresh timer\n    public func startRefreshTimer(interval: TimeInterval = 30.0) {\n        stopRefreshTimer()\n        refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in\n            Task { @MainActor in\n                self?.refresh()\n            }\n        }\n    }\n\n    /// Stop the periodic refresh timer\n    public func stopRefreshTimer() {\n        refreshTimer?.invalidate()\n        refreshTimer = nil\n    }\n\n    /// Clear all participant data\n    public func clear() {\n        participants.removeAll()\n        visiblePeople = []\n    }\n\n    /// Clear participant data for a specific geohash\n    public func clear(geohash: String) {\n        participants.removeValue(forKey: geohash)\n        if activeGeohash == geohash {\n            visiblePeople = []\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/GeohashPresenceService.swift",
    "content": "//\n// GeohashPresenceService.swift\n// bitchat\n//\n// Manages the broadcasting of ephemeral presence heartbeats (Kind 20001)\n// to geohash location channels.\n//\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Foundation\nimport Combine\nimport BitLogger\nimport Tor\n\nprotocol GeohashPresenceTimerProtocol: AnyObject {\n    var isValid: Bool { get }\n    func invalidate()\n}\n\nprivate final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol {\n    private let base: Timer\n\n    init(base: Timer) {\n        self.base = base\n    }\n\n    var isValid: Bool { base.isValid }\n\n    func invalidate() {\n        base.invalidate()\n    }\n}\n\n/// Service that coordinates the broadcasting of presence heartbeats.\n///\n/// Behavior:\n/// - Monitors location changes via LocationStateManager\n/// - Broadcasts Kind 20001 events to low-precision geohash channels\n/// - Uses randomized timing (40-80s loop) and decorrelated bursts\n/// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels\n@MainActor\nfinal class GeohashPresenceService: ObservableObject {\n    static let shared = GeohashPresenceService()\n\n    private var subscriptions = Set<AnyCancellable>()\n    private var heartbeatTimer: GeohashPresenceTimerProtocol?\n    private let availableChannelsProvider: () -> [GeohashChannel]\n    private let locationChanges: AnyPublisher<[GeohashChannel], Never>\n    private let torReadyPublisher: AnyPublisher<Void, Never>\n    private let torIsReady: () -> Bool\n    private let torIsForeground: () -> Bool\n    private let deriveIdentity: (String) throws -> NostrIdentity\n    private let relayLookup: (String, Int) -> [String]\n    private let relaySender: (NostrEvent, [String]) -> Void\n    private let sleeper: (UInt64) async -> Void\n    private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol\n    \n    // MARK: - Constants\n\n    // Loop interval range in seconds\n    private let loopMinInterval: TimeInterval\n    private let loopMaxInterval: TimeInterval\n    \n    // Per-broadcast decorrelation delay range in seconds\n    private let burstMinDelay: TimeInterval\n    private let burstMaxDelay: TimeInterval\n\n    // Privacy: Only broadcast to these levels\n    private let allowedPrecisions: Set<Int> = [\n        GeohashChannelLevel.region.precision,    // 2\n        GeohashChannelLevel.province.precision,  // 4\n        GeohashChannelLevel.city.precision       // 5\n    ]\n\n    private init() {\n        let idBridge = NostrIdentityBridge()\n        self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }\n        self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()\n        self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)\n            .map { _ in () }\n            .eraseToAnyPublisher()\n        self.torIsReady = { TorManager.shared.isReady }\n        self.torIsForeground = { TorManager.shared.isForeground() }\n        self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) }\n        self.relayLookup = { geohash, count in\n            GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)\n        }\n        self.relaySender = { event, relays in\n            NostrRelayManager.shared.sendEvent(event, to: relays)\n        }\n        self.sleeper = { nanoseconds in\n            try? await Task.sleep(nanoseconds: nanoseconds)\n        }\n        self.scheduleTimer = { interval, action in\n            GeohashPresenceTimerAdapter(\n                base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in\n                    action()\n                }\n            )\n        }\n        self.loopMinInterval = 40.0\n        self.loopMaxInterval = 80.0\n        self.burstMinDelay = 2.0\n        self.burstMaxDelay = 5.0\n        setupObservers()\n    }\n\n    internal init(\n        availableChannelsProvider: @escaping () -> [GeohashChannel],\n        locationChanges: AnyPublisher<[GeohashChannel], Never>,\n        torReadyPublisher: AnyPublisher<Void, Never>,\n        torIsReady: @escaping () -> Bool,\n        torIsForeground: @escaping () -> Bool,\n        deriveIdentity: @escaping (String) throws -> NostrIdentity,\n        relayLookup: @escaping (String, Int) -> [String],\n        relaySender: @escaping (NostrEvent, [String]) -> Void,\n        sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) },\n        scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in\n            GeohashPresenceTimerAdapter(\n                base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in\n                    action()\n                }\n            )\n        },\n        loopMinInterval: TimeInterval = 40.0,\n        loopMaxInterval: TimeInterval = 80.0,\n        burstMinDelay: TimeInterval = 2.0,\n        burstMaxDelay: TimeInterval = 5.0\n    ) {\n        self.availableChannelsProvider = availableChannelsProvider\n        self.locationChanges = locationChanges\n        self.torReadyPublisher = torReadyPublisher\n        self.torIsReady = torIsReady\n        self.torIsForeground = torIsForeground\n        self.deriveIdentity = deriveIdentity\n        self.relayLookup = relayLookup\n        self.relaySender = relaySender\n        self.sleeper = sleeper\n        self.scheduleTimer = scheduleTimer\n        self.loopMinInterval = loopMinInterval\n        self.loopMaxInterval = loopMaxInterval\n        self.burstMinDelay = burstMinDelay\n        self.burstMaxDelay = burstMaxDelay\n        setupObservers()\n    }\n    \n    /// Start the service (safe to call multiple times)\n    func start() {\n        SecureLogger.info(\"Presence: service starting...\", category: .session)\n        scheduleNextHeartbeat()\n    }\n\n    private func setupObservers() {\n        // Monitor location channel changes\n        locationChanges\n            .dropFirst()\n            .sink { [weak self] _ in\n                self?.handleLocationChange()\n            }\n            .store(in: &subscriptions)\n\n        // Monitor Tor readiness to kick off heartbeat if it was stalled\n        torReadyPublisher\n            .sink { [weak self] _ in\n                self?.handleConnectivityChange()\n            }\n            .store(in: &subscriptions)\n    }\n\n    func handleLocationChange() {\n        // When location changes, we trigger an immediate (but slightly delayed) heartbeat\n        // to announce presence in the new zone, then reset the loop.\n        SecureLogger.debug(\"Presence: location changed, scheduling update\", category: .session)\n        heartbeatTimer?.invalidate()\n        \n        // Small delay to allow location state to settle\n        heartbeatTimer = scheduleTimer(5.0) { [weak self] in\n            Task { @MainActor [weak self] in\n                self?.performHeartbeat()\n            }\n        }\n    }\n    \n    func handleConnectivityChange() {\n        SecureLogger.debug(\"Presence: connectivity restored, triggering heartbeat\", category: .session)\n        // If we were waiting for network, do it now\n        if heartbeatTimer == nil || !heartbeatTimer!.isValid {\n            scheduleNextHeartbeat()\n        }\n    }\n\n    func scheduleNextHeartbeat() {\n        heartbeatTimer?.invalidate()\n        let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)\n        heartbeatTimer = scheduleTimer(interval) { [weak self] in\n            Task { @MainActor [weak self] in\n                self?.performHeartbeat()\n            }\n        }\n    }\n\n    func performHeartbeat() {\n        // Always schedule next loop first ensures continuity even if this one fails/skips\n        defer { scheduleNextHeartbeat() }\n\n        // 1. Check preconditions\n        guard torIsReady() else {\n            SecureLogger.debug(\"Presence: skipping heartbeat (Tor not ready)\", category: .session)\n            return\n        }\n        \n        // App must be active (or at least we shouldn't broadcast if in background, usually)\n        if !torIsForeground() {\n            return\n        }\n\n        // 2. Get channels\n        let channels = availableChannelsProvider()\n        guard !channels.isEmpty else { return }\n\n        // 3. Filter and broadcast\n        // We use Task + sleep for decorrelation to allow the main runloop to proceed\n        for channel in channels {\n            // Check privacy restriction\n            if !self.allowedPrecisions.contains(channel.geohash.count) {\n                continue\n            }\n            \n            // Launch independent task for each channel's delay\n            Task { @MainActor in\n                // Random delay for decorrelation\n                let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)\n                let nanoseconds = UInt64(delay * 1_000_000_000)\n                await self.sleeper(nanoseconds)\n                \n                self.broadcastPresence(for: channel.geohash)\n            }\n        }\n    }\n\n    func broadcastPresence(for geohash: String) {\n        do {\n            guard let identity = try? deriveIdentity(geohash) else {\n                return\n            }\n            \n            let event = try NostrProtocol.createGeohashPresenceEvent(\n                geohash: geohash,\n                senderIdentity: identity\n            )\n            \n            // Send via RelayManager\n            let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)\n            \n            if !targetRelays.isEmpty {\n                relaySender(event, targetRelays)\n                SecureLogger.debug(\"Presence: sent heartbeat for \\(geohash) (pub=\\(identity.publicKeyHex.prefix(6))...)\", category: .session)\n            }\n        } catch {\n            SecureLogger.error(\"Presence: failed to create event for \\(geohash): \\(error)\", category: .session)\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/KeychainManager.swift",
    "content": "//\n// KeychainManager.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport BitLogger\nimport Foundation\nimport Security\n\n// MARK: - Keychain Error Types\n// BCH-01-009: Proper error classification to distinguish expected states from critical failures\n\n/// Result of a keychain read operation with proper error classification\nenum KeychainReadResult {\n    case success(Data)\n    case itemNotFound        // Expected: key doesn't exist yet\n    case accessDenied        // Critical: app lacks keychain access\n    case deviceLocked        // Recoverable: device is locked\n    case authenticationFailed // Recoverable: biometric/passcode failed\n    case otherError(OSStatus) // Unexpected error\n\n    var isRecoverableError: Bool {\n        switch self {\n        case .deviceLocked, .authenticationFailed:\n            return true\n        default:\n            return false\n        }\n    }\n}\n\n/// Result of a keychain save operation with proper error classification\nenum KeychainSaveResult {\n    case success\n    case duplicateItem       // Can retry with update\n    case accessDenied        // Critical: app lacks keychain access\n    case deviceLocked        // Recoverable: device is locked\n    case storageFull         // Critical: no space available\n    case otherError(OSStatus)\n\n    var isRecoverableError: Bool {\n        switch self {\n        case .duplicateItem, .deviceLocked:\n            return true\n        default:\n            return false\n        }\n    }\n}\n\nprotocol KeychainManagerProtocol {\n    func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool\n    func getIdentityKey(forKey key: String) -> Data?\n    func deleteIdentityKey(forKey key: String) -> Bool\n    func deleteAllKeychainData() -> Bool\n\n    func secureClear(_ data: inout Data)\n    func secureClear(_ string: inout String)\n\n    func verifyIdentityKeyExists() -> Bool\n\n    // BCH-01-009: Methods with proper error classification\n    /// Get identity key with detailed result for error handling\n    func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult\n    /// Save identity key with detailed result for error handling\n    func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult\n\n    // MARK: - Generic Data Storage (consolidated from KeychainHelper)\n    /// Save data with a custom service name\n    func save(key: String, data: Data, service: String, accessible: CFString?)\n    /// Load data from a custom service\n    func load(key: String, service: String) -> Data?\n    /// Delete data from a custom service\n    func delete(key: String, service: String)\n}\n\nfinal class KeychainManager: KeychainManagerProtocol {\n    // Use consistent service name for all keychain items\n    private let service = BitchatApp.bundleID\n    private let appGroup = \"group.\\(BitchatApp.bundleID)\"\n    \n    // MARK: - Identity Keys\n    \n    func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {\n        let fullKey = \"identity_\\(key)\"\n        let result = saveData(keyData, forKey: fullKey)\n        SecureLogger.logKeyOperation(.save, keyType: key, success: result)\n        return result\n    }\n    \n    func getIdentityKey(forKey key: String) -> Data? {\n        let fullKey = \"identity_\\(key)\"\n        return retrieveData(forKey: fullKey)\n    }\n    \n    func deleteIdentityKey(forKey key: String) -> Bool {\n        let result = delete(forKey: \"identity_\\(key)\")\n        SecureLogger.logKeyOperation(.delete, keyType: key, success: result)\n        return result\n    }\n\n    // MARK: - BCH-01-009: Methods with Proper Error Classification\n\n    /// Get identity key with detailed result for proper error handling\n    /// Distinguishes between missing keys (expected) and critical failures\n    func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {\n        let fullKey = \"identity_\\(key)\"\n        return retrieveDataWithResult(forKey: fullKey)\n    }\n\n    /// Save identity key with detailed result and retry logic for transient errors\n    func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {\n        let fullKey = \"identity_\\(key)\"\n        return saveDataWithResult(keyData, forKey: fullKey)\n    }\n\n    /// Internal method to save data with detailed result and retry for transient errors\n    private func saveDataWithResult(_ data: Data, forKey key: String, retryCount: Int = 2) -> KeychainSaveResult {\n        // Delete any existing item first to ensure clean state\n        _ = delete(forKey: key)\n\n        // Build base query\n        var base: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecAttrAccount as String: key,\n            kSecValueData as String: data,\n            kSecAttrService as String: service,\n            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,\n            kSecAttrLabel as String: \"bitchat-\\(key)\"\n        ]\n        #if os(macOS)\n        base[kSecAttrSynchronizable as String] = false\n        #endif\n\n        func attempt(addAccessGroup: Bool) -> OSStatus {\n            var query = base\n            if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup }\n            return SecItemAdd(query as CFDictionary, nil)\n        }\n\n        #if os(iOS)\n        var status = attempt(addAccessGroup: true)\n        if status == -34018 { // Missing entitlement, retry without access group\n            status = attempt(addAccessGroup: false)\n        }\n        #else\n        let status = attempt(addAccessGroup: false)\n        #endif\n\n        // Classify the result\n        let result = classifySaveStatus(status)\n\n        // Log all outcomes consistently\n        switch result {\n        case .success:\n            SecureLogger.debug(\"Keychain save succeeded for key: \\(key)\", category: .keychain)\n        case .duplicateItem:\n            SecureLogger.warning(\"Keychain save found duplicate for key: \\(key)\", category: .keychain)\n        case .accessDenied:\n            SecureLogger.error(NSError(domain: \"Keychain\", code: Int(status)),\n                               context: \"Keychain access denied for key: \\(key)\", category: .keychain)\n        case .deviceLocked:\n            SecureLogger.warning(\"Device locked during keychain save for key: \\(key)\", category: .keychain)\n        case .storageFull:\n            SecureLogger.error(NSError(domain: \"Keychain\", code: Int(status)),\n                               context: \"Keychain storage full for key: \\(key)\", category: .keychain)\n        case .otherError(let code):\n            SecureLogger.error(NSError(domain: \"Keychain\", code: Int(code)),\n                               context: \"Keychain save failed for key: \\(key)\", category: .keychain)\n        }\n\n        // Retry transient errors with exponential backoff\n        if result.isRecoverableError && retryCount > 0 {\n            let delayMs = UInt32((3 - retryCount) * 100) // 100ms, 200ms backoff\n            usleep(delayMs * 1000)\n            SecureLogger.debug(\"Retrying keychain save for key: \\(key), attempts remaining: \\(retryCount)\", category: .keychain)\n            return saveDataWithResult(data, forKey: key, retryCount: retryCount - 1)\n        }\n\n        return result\n    }\n\n    /// Internal method to retrieve data with detailed result\n    private func retrieveDataWithResult(forKey key: String) -> KeychainReadResult {\n        let base: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecAttrAccount as String: key,\n            kSecAttrService as String: service,\n            kSecReturnData as String: true,\n            kSecMatchLimit as String: kSecMatchLimitOne\n        ]\n\n        var result: AnyObject?\n        func attempt(withAccessGroup: Bool) -> OSStatus {\n            var q = base\n            if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }\n            return SecItemCopyMatching(q as CFDictionary, &result)\n        }\n\n        #if os(iOS)\n        var status = attempt(withAccessGroup: true)\n        if status == -34018 { status = attempt(withAccessGroup: false) }\n        #else\n        let status = attempt(withAccessGroup: false)\n        #endif\n\n        // Classify the result\n        let readResult = classifyReadStatus(status, data: result as? Data)\n\n        // Log all outcomes consistently\n        switch readResult {\n        case .success:\n            SecureLogger.debug(\"Keychain read succeeded for key: \\(key)\", category: .keychain)\n        case .itemNotFound:\n            // Expected case - no logging needed for missing keys\n            break\n        case .accessDenied:\n            SecureLogger.error(NSError(domain: \"Keychain\", code: Int(status)),\n                               context: \"Keychain access denied for key: \\(key)\", category: .keychain)\n        case .deviceLocked:\n            SecureLogger.warning(\"Device locked during keychain read for key: \\(key)\", category: .keychain)\n        case .authenticationFailed:\n            SecureLogger.warning(\"Authentication failed for keychain read of key: \\(key)\", category: .keychain)\n        case .otherError(let code):\n            SecureLogger.error(NSError(domain: \"Keychain\", code: Int(code)),\n                               context: \"Keychain read failed for key: \\(key)\", category: .keychain)\n        }\n\n        return readResult\n    }\n\n    /// Classify keychain read status into meaningful categories\n    private func classifyReadStatus(_ status: OSStatus, data: Data?) -> KeychainReadResult {\n        switch status {\n        case errSecSuccess:\n            if let data = data {\n                return .success(data)\n            }\n            return .otherError(status)\n        case errSecItemNotFound:\n            return .itemNotFound\n        case errSecInteractionNotAllowed:\n            // Device is locked or in a state that doesn't allow keychain access\n            return .deviceLocked\n        case errSecAuthFailed:\n            return .authenticationFailed\n        case -34018: // errSecMissingEntitlement\n            return .accessDenied\n        case errSecNotAvailable:\n            return .accessDenied\n        default:\n            return .otherError(status)\n        }\n    }\n\n    /// Classify keychain save status into meaningful categories\n    private func classifySaveStatus(_ status: OSStatus) -> KeychainSaveResult {\n        switch status {\n        case errSecSuccess:\n            return .success\n        case errSecDuplicateItem:\n            return .duplicateItem\n        case errSecInteractionNotAllowed:\n            return .deviceLocked\n        case -34018: // errSecMissingEntitlement\n            return .accessDenied\n        case errSecNotAvailable:\n            return .accessDenied\n        case errSecDiskFull:\n            return .storageFull\n        default:\n            return .otherError(status)\n        }\n    }\n\n    // MARK: - Generic Operations\n    \n    private func save(_ value: String, forKey key: String) -> Bool {\n        guard let data = value.data(using: .utf8) else { return false }\n        return saveData(data, forKey: key)\n    }\n    \n    private func saveData(_ data: Data, forKey key: String) -> Bool {\n        // Delete any existing item first to ensure clean state\n        _ = delete(forKey: key)\n        \n        // Build base query\n        var base: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecAttrAccount as String: key,\n            kSecValueData as String: data,\n            kSecAttrService as String: service,\n            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,\n            kSecAttrLabel as String: \"bitchat-\\(key)\"\n        ]\n        #if os(macOS)\n        base[kSecAttrSynchronizable as String] = false\n        #endif\n\n        // Try with access group where it is expected to work (iOS app builds)\n        var triedWithoutGroup = false\n        func attempt(addAccessGroup: Bool) -> OSStatus {\n            var query = base\n            if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup }\n            return SecItemAdd(query as CFDictionary, nil)\n        }\n\n        #if os(iOS)\n        var status = attempt(addAccessGroup: true)\n        if status == -34018 { // Missing entitlement, retry without access group\n            triedWithoutGroup = true\n            status = attempt(addAccessGroup: false)\n        }\n        #else\n        // On macOS dev/simulator default to no access group to avoid -34018\n        let status = attempt(addAccessGroup: false)\n        #endif\n\n        if status == errSecSuccess { return true }\n        if status == -34018 && !triedWithoutGroup {\n            SecureLogger.error(NSError(domain: \"Keychain\", code: -34018), context: \"Missing keychain entitlement\", category: .keychain)\n        } else if status != errSecDuplicateItem {\n            SecureLogger.error(NSError(domain: \"Keychain\", code: Int(status)), context: \"Error saving to keychain\", category: .keychain)\n        }\n        return false\n    }\n    \n    private func retrieve(forKey key: String) -> String? {\n        guard let data = retrieveData(forKey: key) else { return nil }\n        return String(data: data, encoding: .utf8)\n    }\n    \n    private func retrieveData(forKey key: String) -> Data? {\n        // Base query\n        let base: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecAttrAccount as String: key,\n            kSecAttrService as String: service,\n            kSecReturnData as String: true,\n            kSecMatchLimit as String: kSecMatchLimitOne\n        ]\n\n        var result: AnyObject?\n        func attempt(withAccessGroup: Bool) -> OSStatus {\n            var q = base\n            if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }\n            return SecItemCopyMatching(q as CFDictionary, &result)\n        }\n\n        #if os(iOS)\n        var status = attempt(withAccessGroup: true)\n        if status == -34018 { status = attempt(withAccessGroup: false) }\n        #else\n        let status = attempt(withAccessGroup: false)\n        #endif\n\n        if status == errSecSuccess { return result as? Data }\n        if status == -34018 {\n            SecureLogger.error(NSError(domain: \"Keychain\", code: -34018), context: \"Missing keychain entitlement\", category: .keychain)\n        }\n        return nil\n    }\n    \n    private func delete(forKey key: String) -> Bool {\n        // Base delete query\n        let base: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecAttrAccount as String: key,\n            kSecAttrService as String: service\n        ]\n\n        func attempt(withAccessGroup: Bool) -> OSStatus {\n            var q = base\n            if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }\n            return SecItemDelete(q as CFDictionary)\n        }\n\n        #if os(iOS)\n        var status = attempt(withAccessGroup: true)\n        if status == -34018 { status = attempt(withAccessGroup: false) }\n        #else\n        let status = attempt(withAccessGroup: false)\n        #endif\n        return status == errSecSuccess || status == errSecItemNotFound\n    }\n    \n    // MARK: - Cleanup\n    \n    func deleteAllPasswords() -> Bool {\n        var query: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword\n        ]\n        \n        // Add service if not empty\n        if !service.isEmpty {\n            query[kSecAttrService as String] = service\n        }\n        \n        let status = SecItemDelete(query as CFDictionary)\n        return status == errSecSuccess || status == errSecItemNotFound\n    }\n    \n    \n    // Delete ALL keychain data for panic mode\n    func deleteAllKeychainData() -> Bool {\n        SecureLogger.warning(\"Panic mode - deleting all keychain data\", category: .security)\n        \n        var totalDeleted = 0\n        \n        // Search without service restriction to catch all items\n        let searchQuery: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecMatchLimit as String: kSecMatchLimitAll,\n            kSecReturnAttributes as String: true\n        ]\n        \n        var result: AnyObject?\n        let searchStatus = SecItemCopyMatching(searchQuery as CFDictionary, &result)\n        \n        if searchStatus == errSecSuccess, let items = result as? [[String: Any]] {\n            for item in items {\n                var shouldDelete = false\n                let account = item[kSecAttrAccount as String] as? String ?? \"\"\n                let service = item[kSecAttrService as String] as? String ?? \"\"\n                let accessGroup = item[kSecAttrAccessGroup as String] as? String\n                \n                // More precise deletion criteria:\n                // 1. Check for our specific app group\n                // 2. OR check for our exact service name\n                // 3. OR check for known legacy service names\n                if accessGroup == appGroup {\n                    shouldDelete = true\n                } else if service == self.service {\n                    shouldDelete = true\n                } else if [\n                    \"com.bitchat.passwords\",\n                    \"com.bitchat.deviceidentity\",\n                    \"com.bitchat.noise.identity\",\n                    \"chat.bitchat.passwords\",\n                    \"bitchat.keychain\",\n                    \"bitchat\",\n                    \"com.bitchat\"\n                ].contains(service) {\n                    shouldDelete = true\n                }\n                \n                if shouldDelete {\n                    // Build delete query with all available attributes for precise deletion\n                    var deleteQuery: [String: Any] = [\n                        kSecClass as String: kSecClassGenericPassword\n                    ]\n                    \n                    if !account.isEmpty {\n                        deleteQuery[kSecAttrAccount as String] = account\n                    }\n                    if !service.isEmpty {\n                        deleteQuery[kSecAttrService as String] = service\n                    }\n                    \n                    // Add access group if present\n                    if let accessGroup = item[kSecAttrAccessGroup as String] as? String,\n                       !accessGroup.isEmpty && accessGroup != \"test\" {\n                        deleteQuery[kSecAttrAccessGroup as String] = accessGroup\n                    }\n                    \n                    let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)\n                    if deleteStatus == errSecSuccess {\n                        totalDeleted += 1\n                        SecureLogger.info(\"Deleted keychain item: \\(account) from \\(service)\", category: .keychain)\n                    }\n                }\n            }\n        }\n        \n        // Also try to delete by known service names and app group\n        // This catches any items that might have been missed above\n        let knownServices = [\n            self.service,  // Current service name\n            \"com.bitchat.passwords\",\n            \"com.bitchat.deviceidentity\", \n            \"com.bitchat.noise.identity\",\n            \"chat.bitchat.passwords\",\n            \"chat.bitchat.nostr\",\n            \"bitchat.keychain\",\n            \"bitchat\",\n            \"com.bitchat\"\n        ]\n        \n        for serviceName in knownServices {\n            let query: [String: Any] = [\n                kSecClass as String: kSecClassGenericPassword,\n                kSecAttrService as String: serviceName\n            ]\n            \n            let status = SecItemDelete(query as CFDictionary)\n            if status == errSecSuccess {\n                totalDeleted += 1\n            }\n        }\n        \n        // Also delete by app group to ensure complete cleanup\n        let groupQuery: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecAttrAccessGroup as String: appGroup\n        ]\n        \n        let groupStatus = SecItemDelete(groupQuery as CFDictionary)\n        if groupStatus == errSecSuccess {\n            totalDeleted += 1\n        }\n        \n        SecureLogger.warning(\"Panic mode cleanup completed. Total items deleted: \\(totalDeleted)\", category: .keychain)\n        \n        return totalDeleted > 0\n    }\n    \n    // MARK: - Security Utilities\n    \n    /// Securely clear sensitive data from memory\n    func secureClear(_ data: inout Data) {\n        _ = data.withUnsafeMutableBytes { bytes in\n            // Use volatile memset to prevent compiler optimization\n            memset_s(bytes.baseAddress, bytes.count, 0, bytes.count)\n        }\n        data = Data() // Clear the data object\n    }\n    \n    /// Securely clear sensitive string from memory\n    func secureClear(_ string: inout String) {\n        // Convert to mutable data and clear\n        if var data = string.data(using: .utf8) {\n            secureClear(&data)\n        }\n        string = \"\" // Clear the string object\n    }\n    \n    // MARK: - Debug\n\n    func verifyIdentityKeyExists() -> Bool {\n        let key = \"identity_noiseStaticKey\"\n        return retrieveData(forKey: key) != nil\n    }\n\n    // MARK: - Generic Data Storage (consolidated from KeychainHelper)\n\n    /// Save data with a custom service name\n    func save(key: String, data: Data, service customService: String, accessible: CFString?) {\n        var query: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecAttrService as String: customService,\n            kSecAttrAccount as String: key,\n            kSecValueData as String: data\n        ]\n        if let accessible = accessible {\n            query[kSecAttrAccessible as String] = accessible\n        }\n\n        SecItemDelete(query as CFDictionary)\n        SecItemAdd(query as CFDictionary, nil)\n    }\n\n    /// Load data from a custom service\n    func load(key: String, service customService: String) -> Data? {\n        let query: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecAttrService as String: customService,\n            kSecAttrAccount as String: key,\n            kSecReturnData as String: true\n        ]\n\n        var result: AnyObject?\n        let status = SecItemCopyMatching(query as CFDictionary, &result)\n\n        guard status == errSecSuccess else { return nil }\n        return result as? Data\n    }\n\n    /// Delete data from a custom service\n    func delete(key: String, service customService: String) {\n        let query: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecAttrService as String: customService,\n            kSecAttrAccount as String: key\n        ]\n\n        SecItemDelete(query as CFDictionary)\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/LocationNotesManager.swift",
    "content": "import BitLogger\nimport Foundation\n\n/// Dependencies for location notes, allowing tests to stub relay/identity behavior.\nstruct LocationNotesDependencies {\n    typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]\n    typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void\n    typealias Unsubscribe = @MainActor (_ id: String) -> Void\n    typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void\n\n    var relayLookup: RelayLookup\n    var subscribe: Subscribe\n    var unsubscribe: Unsubscribe\n    var sendEvent: SendEvent\n    var deriveIdentity: (_ geohash: String) throws -> NostrIdentity\n    var now: () -> Date\n    \n    private static let idBridge = NostrIdentityBridge()\n\n    static let live = LocationNotesDependencies(\n        relayLookup: { geohash, count in\n            GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)\n        },\n        subscribe: { filter, id, relays, handler, onEOSE in\n            NostrRelayManager.shared.subscribe(\n                filter: filter,\n                id: id,\n                relayUrls: relays,\n                handler: handler,\n                onEOSE: onEOSE\n            )\n        },\n        unsubscribe: { id in\n            NostrRelayManager.shared.unsubscribe(id: id)\n        },\n        sendEvent: { event, relays in\n            NostrRelayManager.shared.sendEvent(event, to: relays)\n        },\n        deriveIdentity: { geohash in\n            try idBridge.deriveIdentity(forGeohash: geohash)\n        },\n        now: { Date() }\n    )\n}\n\n/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8).\n/// Subscribes to and publishes notes for a given geohash and provides a send API.\n@MainActor\nfinal class LocationNotesManager: ObservableObject {\n    enum State: Equatable {\n        case idle\n        case loading\n        case ready\n        case noRelays\n    }\n\n    struct Note: Identifiable, Equatable {\n        let id: String\n        let pubkey: String\n        let content: String\n        let createdAt: Date\n        let nickname: String?\n\n        var displayName: String {\n            let suffix = String(pubkey.suffix(4))\n            if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n                return \"\\(nick)#\\(suffix)\"\n            }\n            return \"anon#\\(suffix)\"\n        }\n    }\n\n    @Published private(set) var notes: [Note] = [] // reverse-chron sorted\n    @Published private(set) var geohash: String\n    @Published private(set) var initialLoadComplete: Bool = false\n    @Published private(set) var state: State = .loading\n    @Published private(set) var errorMessage: String?\n    private var subscriptionID: String?\n    private var noteIDs = Set<String>() // O(1) duplicate detection\n    private let dependencies: LocationNotesDependencies\n    private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)\n\n    private enum Strings {\n        static let noRelays = String(localized: \"location_notes.error.no_relays\", comment: \"Shown when no geo relays are available near the selected location\")\n\n        static func failedToSend(_ detail: String) -> String {\n            String(\n                format: String(localized: \"location_notes.error.failed_to_send\", comment: \"Shown when a location note fails to send\"),\n                locale: .current,\n                detail\n            )\n        }\n    }\n\n    init(geohash: String, dependencies: LocationNotesDependencies = .live) {\n        let norm = geohash.lowercased()\n        self.geohash = norm\n        self.dependencies = dependencies\n        // Validate geohash (building-level precision: 8 chars)\n        if !Geohash.isValidBuildingGeohash(norm) {\n            SecureLogger.warning(\"LocationNotesManager: invalid geohash '\\(norm)' (expected 8 valid base32 chars)\", category: .session)\n        }\n        subscribe()\n    }\n\n    func setGeohash(_ newGeohash: String) {\n        let norm = newGeohash.lowercased()\n        guard norm != geohash else { return }\n        // Validate geohash (building-level precision: 8 chars)\n        guard Geohash.isValidBuildingGeohash(norm) else {\n            SecureLogger.warning(\"LocationNotesManager: rejecting invalid geohash '\\(norm)' (expected 8 valid base32 chars)\", category: .session)\n            return\n        }\n        if let sub = subscriptionID {\n            dependencies.unsubscribe(sub)\n            subscriptionID = nil\n        }\n        // Set loading state before clearing to prevent empty state flicker\n        state = .loading\n        initialLoadComplete = false\n        errorMessage = nil\n        geohash = norm\n        notes.removeAll()\n        noteIDs.removeAll()\n        subscribe()\n    }\n\n    func refresh() {\n        if let sub = subscriptionID {\n            dependencies.unsubscribe(sub)\n            subscriptionID = nil\n        }\n        // Set loading state before clearing to prevent empty state flicker\n        state = .loading\n        initialLoadComplete = false\n        errorMessage = nil\n        notes.removeAll()\n        noteIDs.removeAll()\n        subscribe()\n    }\n\n    func clearError() {\n        errorMessage = nil\n    }\n\n    private func subscribe() {\n        state = .loading\n        errorMessage = nil\n        if let sub = subscriptionID {\n            dependencies.unsubscribe(sub)\n            subscriptionID = nil\n        }\n        let subID = \"locnotes-\\(geohash)-\\(UUID().uuidString.prefix(8))\"\n        let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)\n        guard !relays.isEmpty else {\n            subscriptionID = nil\n            initialLoadComplete = true\n            state = .noRelays\n            errorMessage = Strings.noRelays\n            SecureLogger.warning(\"LocationNotesManager: no geo relays for geohash=\\(geohash)\", category: .session)\n            return\n        }\n\n        subscriptionID = subID\n        initialLoadComplete = false\n\n        // Subscribe to center + 8 neighbors (± 1 grid)\n        let neighbors = Geohash.neighbors(of: geohash)\n        let allGeohashes = [geohash] + neighbors\n        let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)\n\n        // Build a set of valid geohashes for tag matching (includes all 9 cells)\n        let validGeohashes = Set(allGeohashes.map { $0.lowercased() })\n\n        dependencies.subscribe(filter, subID, relays, { [weak self] event in\n            guard let self = self else { return }\n            guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }\n            // Ensure matching tag - accept any of our 9 geohashes\n            guard event.tags.contains(where: { tag in\n                tag.count >= 2 && tag[0].lowercased() == \"g\" && validGeohashes.contains(tag[1].lowercased())\n            }) else { return }\n            guard !self.noteIDs.contains(event.id) else { return }\n            self.noteIDs.insert(event.id)\n            let nick = event.tags.first(where: { $0.first?.lowercased() == \"n\" && $0.count >= 2 })?.dropFirst().first\n            let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))\n            let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)\n            self.notes.append(note)\n            self.notes.sort { $0.createdAt > $1.createdAt }\n            self.enforceMemoryCap()\n            self.state = .ready\n        }, { [weak self] in\n            guard let self = self else { return }\n            self.initialLoadComplete = true\n            if self.state != .noRelays {\n                self.state = .ready\n            }\n        })\n    }\n\n    /// Send a location note for the current geohash using the per-geohash identity.\n    func send(content: String, nickname: String) {\n        let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty else { return }\n        let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)\n        guard !relays.isEmpty else {\n            state = .noRelays\n            errorMessage = Strings.noRelays\n            SecureLogger.warning(\"LocationNotesManager: send blocked, no geo relays for geohash=\\(geohash)\", category: .session)\n            return\n        }\n        do {\n            let id = try dependencies.deriveIdentity(geohash)\n            let event = try NostrProtocol.createGeohashTextNote(\n                content: trimmed,\n                geohash: geohash,\n                senderIdentity: id,\n                nickname: nickname\n            )\n            dependencies.sendEvent(event, relays)\n            // Optimistic local-echo\n            let echo = Note(\n                id: event.id,\n                pubkey: id.publicKeyHex,\n                content: trimmed,\n                createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),\n                nickname: nickname\n            )\n            self.noteIDs.insert(event.id)\n            self.notes.insert(echo, at: 0)\n            self.enforceMemoryCap()\n            self.state = .ready\n            self.errorMessage = nil\n        } catch {\n            SecureLogger.error(\"LocationNotesManager: failed to send note: \\(error)\", category: .session)\n            errorMessage = Strings.failedToSend(error.localizedDescription)\n        }\n    }\n\n    /// Enforces defensive memory cap on notes array (keeps newest).\n    private func enforceMemoryCap() {\n        if notes.count > maxNotesInMemory {\n            let removed = notes.count - maxNotesInMemory\n            notes = Array(notes.prefix(maxNotesInMemory))\n            SecureLogger.debug(\"LocationNotesManager: trimmed \\(removed) old notes (cap: \\(maxNotesInMemory))\", category: .session)\n        }\n    }\n\n    /// Explicitly cancel subscription and release resources.\n    func cancel() {\n        if let sub = subscriptionID {\n            dependencies.unsubscribe(sub)\n            subscriptionID = nil\n        }\n        state = .idle\n        errorMessage = nil\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/LocationStateManager.swift",
    "content": "import BitLogger\nimport Foundation\nimport Combine\n\n#if os(iOS) || os(macOS)\nimport CoreLocation\n\nprotocol LocationStateManaging: AnyObject {\n    var delegate: CLLocationManagerDelegate? { get set }\n    var desiredAccuracy: CLLocationAccuracy { get set }\n    var distanceFilter: CLLocationDistance { get set }\n    var authorizationStatus: CLAuthorizationStatus { get }\n    func requestWhenInUseAuthorization()\n    func requestLocation()\n    func startUpdatingLocation()\n    func stopUpdatingLocation()\n}\n\nprotocol LocationStateGeocoding: AnyObject {\n    func cancelGeocode()\n    func reverseGeocodeLocation(\n        _ location: CLLocation,\n        completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void\n    )\n}\n\nprivate final class CLLocationManagerAdapter: NSObject, LocationStateManaging {\n    private let base = CLLocationManager()\n\n    var delegate: CLLocationManagerDelegate? {\n        get { base.delegate }\n        set { base.delegate = newValue }\n    }\n\n    var desiredAccuracy: CLLocationAccuracy {\n        get { base.desiredAccuracy }\n        set { base.desiredAccuracy = newValue }\n    }\n\n    var distanceFilter: CLLocationDistance {\n        get { base.distanceFilter }\n        set { base.distanceFilter = newValue }\n    }\n\n    var authorizationStatus: CLAuthorizationStatus {\n        base.authorizationStatus\n    }\n\n    func requestWhenInUseAuthorization() {\n        base.requestWhenInUseAuthorization()\n    }\n\n    func requestLocation() {\n        base.requestLocation()\n    }\n\n    func startUpdatingLocation() {\n        base.startUpdatingLocation()\n    }\n\n    func stopUpdatingLocation() {\n        base.stopUpdatingLocation()\n    }\n}\n\nprivate final class CLGeocoderAdapter: LocationStateGeocoding {\n    private let base = CLGeocoder()\n\n    func cancelGeocode() {\n        base.cancelGeocode()\n    }\n\n    func reverseGeocodeLocation(\n        _ location: CLLocation,\n        completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void\n    ) {\n        base.reverseGeocodeLocation(location, completionHandler: completionHandler)\n    }\n}\n\n/// Unified manager for location-based channel state including:\n/// - CoreLocation permissions and one-shot location retrieval\n/// - Geohash channel computation from coordinates\n/// - Channel selection and teleport state\n/// - Bookmark persistence and friendly name resolution\n///\n/// Consolidates LocationChannelManager + GeohashBookmarksStore into a single source of truth.\nfinal class LocationStateManager: NSObject, CLLocationManagerDelegate, ObservableObject {\n    static let shared = LocationStateManager()\n\n    // MARK: - Permission State\n\n    enum PermissionState: Equatable {\n        case notDetermined\n        case denied\n        case restricted\n        case authorized\n    }\n\n    // MARK: - Private Properties (CoreLocation)\n\n    private let cl: LocationStateManaging\n    private let geocoder: LocationStateGeocoding\n    private var lastLocation: CLLocation?\n    private var refreshTimer: Timer?\n    private var isGeocoding: Bool = false\n\n    // MARK: - Persistence Keys\n\n    private let selectedChannelKey = \"locationChannel.selected\"\n    private let teleportedStoreKey = \"locationChannel.teleportedSet\"\n    private let bookmarksKey = \"locationChannel.bookmarks\"\n    private let bookmarkNamesKey = \"locationChannel.bookmarkNames\"\n\n    // MARK: - Published State (Channel)\n\n    @Published private(set) var permissionState: PermissionState = .notDetermined\n    @Published private(set) var availableChannels: [GeohashChannel] = []\n    @Published private(set) var selectedChannel: ChannelID = .mesh\n    @Published var teleported: Bool = false\n    @Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]\n\n    // MARK: - Published State (Bookmarks)\n\n    @Published private(set) var bookmarks: [String] = []\n    @Published private(set) var bookmarkNames: [String: String] = [:]\n\n    // MARK: - Private State\n\n    private var teleportedSet: Set<String> = []\n    private var bookmarkMembership: Set<String> = []\n    private var resolvingNames: Set<String> = []\n    private let storage: UserDefaults\n\n    /// Returns true if running in test environment\n    private static var isRunningTests: Bool {\n        let env = ProcessInfo.processInfo.environment\n        return NSClassFromString(\"XCTestCase\") != nil ||\n               env[\"XCTestConfigurationFilePath\"] != nil ||\n               env[\"XCTestBundlePath\"] != nil ||\n               env[\"GITHUB_ACTIONS\"] != nil ||\n               env[\"CI\"] != nil\n    }\n\n    // MARK: - Initialization\n\n    private override init() {\n        self.storage = .standard\n        self.cl = CLLocationManagerAdapter()\n        self.geocoder = CLGeocoderAdapter()\n        super.init()\n\n        // Skip CoreLocation setup in test environments\n        guard !Self.isRunningTests else {\n            loadPersistedState()\n            return\n        }\n\n        cl.delegate = self\n        cl.desiredAccuracy = kCLLocationAccuracyHundredMeters\n        cl.distanceFilter = TransportConfig.locationDistanceFilterMeters\n\n        loadPersistedState()\n        initializePermissionState()\n    }\n\n    /// Internal initializer for testing with custom storage\n    init(storage: UserDefaults) {\n        self.storage = storage\n        self.cl = CLLocationManagerAdapter()\n        self.geocoder = CLGeocoderAdapter()\n        super.init()\n        loadPersistedState()\n    }\n\n    internal init(\n        storage: UserDefaults,\n        locationManager: LocationStateManaging,\n        geocoder: LocationStateGeocoding,\n        shouldInitializeCoreLocation: Bool\n    ) {\n        self.storage = storage\n        self.cl = locationManager\n        self.geocoder = geocoder\n        super.init()\n        loadPersistedState()\n        guard shouldInitializeCoreLocation else { return }\n        cl.delegate = self\n        cl.desiredAccuracy = kCLLocationAccuracyHundredMeters\n        cl.distanceFilter = TransportConfig.locationDistanceFilterMeters\n        initializePermissionState()\n    }\n\n    private func loadPersistedState() {\n        // Load selected channel\n        if let data = storage.data(forKey: selectedChannelKey),\n           let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {\n            selectedChannel = channel\n        }\n\n        // Load teleported set\n        if let data = storage.data(forKey: teleportedStoreKey),\n           let arr = try? JSONDecoder().decode([String].self, from: data) {\n            teleportedSet = Set(arr)\n        }\n\n        // Load bookmarks\n        if let data = storage.data(forKey: bookmarksKey),\n           let arr = try? JSONDecoder().decode([String].self, from: data) {\n            var seen = Set<String>()\n            var list: [String] = []\n            for raw in arr {\n                let gh = Self.normalizeGeohash(raw)\n                guard !gh.isEmpty, !seen.contains(gh) else { continue }\n                seen.insert(gh)\n                list.append(gh)\n            }\n            bookmarks = list\n            bookmarkMembership = seen\n        }\n\n        // Load bookmark names\n        if let data = storage.data(forKey: bookmarkNamesKey),\n           let dict = try? JSONDecoder().decode([String: String].self, from: data) {\n            bookmarkNames = dict\n        }\n    }\n\n    private func initializePermissionState() {\n        let status = cl.authorizationStatus\n        updatePermissionState(from: status)\n\n        // Fall back to persisted teleport state if no location authorization\n        switch status {\n        case .authorizedAlways, .authorizedWhenInUse, .authorized:\n            break\n        case .notDetermined, .restricted, .denied:\n            fallthrough\n        @unknown default:\n            if case .location(let ch) = selectedChannel {\n                teleported = teleportedSet.contains(ch.geohash)\n            }\n        }\n    }\n\n    // MARK: - Public API (Permissions & Location)\n\n    func enableLocationChannels() {\n        let status = cl.authorizationStatus\n        switch status {\n        case .notDetermined:\n            cl.requestWhenInUseAuthorization()\n        case .restricted:\n            Task { @MainActor in self.permissionState = .restricted }\n        case .denied:\n            Task { @MainActor in self.permissionState = .denied }\n        case .authorizedAlways, .authorizedWhenInUse, .authorized:\n            Task { @MainActor in self.permissionState = .authorized }\n            requestOneShotLocation()\n        @unknown default:\n            Task { @MainActor in self.permissionState = .restricted }\n        }\n    }\n\n    func refreshChannels() {\n        if permissionState == .authorized {\n            requestOneShotLocation()\n        }\n    }\n\n    func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {\n        guard permissionState == .authorized else { return }\n        refreshTimer?.invalidate()\n        refreshTimer = nil\n        cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters\n        cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters\n        cl.startUpdatingLocation()\n        requestOneShotLocation()\n    }\n\n    func endLiveRefresh() {\n        refreshTimer?.invalidate()\n        refreshTimer = nil\n        cl.stopUpdatingLocation()\n        cl.desiredAccuracy = kCLLocationAccuracyHundredMeters\n        cl.distanceFilter = TransportConfig.locationDistanceFilterMeters\n    }\n\n    // MARK: - Public API (Channel Selection)\n\n    func select(_ channel: ChannelID) {\n        Task { @MainActor in\n            self.selectedChannel = channel\n            if let data = try? JSONEncoder().encode(channel) {\n                self.storage.set(data, forKey: self.selectedChannelKey)\n            }\n\n            switch channel {\n            case .mesh:\n                self.teleported = false\n            case .location(let ch):\n                let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }\n                if inRegional {\n                    self.teleported = false\n                    if self.teleportedSet.contains(ch.geohash) {\n                        self.teleportedSet.remove(ch.geohash)\n                        self.persistTeleportedSet()\n                    }\n                } else {\n                    self.teleported = self.teleportedSet.contains(ch.geohash)\n                }\n            }\n        }\n    }\n\n    func markTeleported(for geohash: String, _ flag: Bool) {\n        if flag {\n            teleportedSet.insert(geohash)\n        } else {\n            teleportedSet.remove(geohash)\n        }\n        persistTeleportedSet()\n        if case .location(let ch) = selectedChannel, ch.geohash == geohash {\n            Task { @MainActor in self.teleported = flag }\n        }\n    }\n\n    // MARK: - Public API (Bookmarks)\n\n    func isBookmarked(_ geohash: String) -> Bool {\n        bookmarkMembership.contains(Self.normalizeGeohash(geohash))\n    }\n\n    func toggleBookmark(_ geohash: String) {\n        let gh = Self.normalizeGeohash(geohash)\n        if bookmarkMembership.contains(gh) {\n            removeBookmark(gh)\n        } else {\n            addBookmark(gh)\n        }\n    }\n\n    func addBookmark(_ geohash: String) {\n        let gh = Self.normalizeGeohash(geohash)\n        guard !gh.isEmpty, !bookmarkMembership.contains(gh) else { return }\n        bookmarks.insert(gh, at: 0)\n        bookmarkMembership.insert(gh)\n        persistBookmarks()\n        resolveBookmarkNameIfNeeded(for: gh)\n    }\n\n    func removeBookmark(_ geohash: String) {\n        let gh = Self.normalizeGeohash(geohash)\n        guard bookmarkMembership.contains(gh) else { return }\n        if let idx = bookmarks.firstIndex(of: gh) {\n            bookmarks.remove(at: idx)\n        }\n        bookmarkMembership.remove(gh)\n        if bookmarkNames.removeValue(forKey: gh) != nil {\n            persistBookmarkNames()\n        }\n        persistBookmarks()\n    }\n\n    // MARK: - CLLocationManagerDelegate\n\n    private func requestOneShotLocation() {\n        cl.requestLocation()\n    }\n\n    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {\n        updatePermissionState(from: status)\n        if case .authorized = permissionState {\n            requestOneShotLocation()\n        }\n    }\n\n    @available(iOS 14.0, macOS 11.0, *)\n    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {\n        updatePermissionState(from: manager.authorizationStatus)\n        if case .authorized = permissionState {\n            requestOneShotLocation()\n        }\n    }\n\n    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {\n        guard let loc = locations.last else { return }\n        lastLocation = loc\n        computeChannels(from: loc.coordinate)\n        reverseGeocodeLocation(loc)\n    }\n\n    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {\n        SecureLogger.error(\"LocationStateManager: location error: \\(error.localizedDescription)\", category: .session)\n    }\n\n    // MARK: - Private Helpers (Permission)\n\n    private func updatePermissionState(from status: CLAuthorizationStatus) {\n        let newState: PermissionState\n        switch status {\n        case .notDetermined: newState = .notDetermined\n        case .restricted: newState = .restricted\n        case .denied: newState = .denied\n        case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized\n        @unknown default: newState = .restricted\n        }\n        Task { @MainActor in self.permissionState = newState }\n    }\n\n    // MARK: - Private Helpers (Channel Computation)\n\n    private func computeChannels(from coord: CLLocationCoordinate2D) {\n        let levels = GeohashChannelLevel.allCases\n        var result: [GeohashChannel] = []\n        for level in levels {\n            let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)\n            result.append(GeohashChannel(level: level, geohash: gh))\n        }\n        Task { @MainActor in\n            self.availableChannels = result\n            switch self.selectedChannel {\n            case .mesh:\n                self.teleported = false\n            case .location(let ch):\n                let inRegional = result.contains { $0.geohash == ch.geohash }\n                if inRegional {\n                    self.teleported = false\n                    if self.teleportedSet.contains(ch.geohash) {\n                        self.teleportedSet.remove(ch.geohash)\n                        self.persistTeleportedSet()\n                    }\n                } else {\n                    self.teleported = true\n                }\n            }\n        }\n    }\n\n    // MARK: - Private Helpers (Geocoding)\n\n    private func reverseGeocodeLocation(_ location: CLLocation) {\n        geocoder.cancelGeocode()\n        isGeocoding = true\n        geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in\n            guard let self = self else { return }\n            self.isGeocoding = false\n            if let pm = placemarks?.first {\n                let names = self.locationNamesByLevel(from: pm)\n                Task { @MainActor in self.locationNames = names }\n            }\n        }\n    }\n\n    private func locationNamesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {\n        var dict: [GeohashChannelLevel: String] = [:]\n        if let country = pm.country, !country.isEmpty {\n            dict[.region] = country\n        }\n        if let admin = pm.administrativeArea, !admin.isEmpty {\n            dict[.province] = admin\n        } else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {\n            dict[.province] = subAdmin\n        }\n        if let locality = pm.locality, !locality.isEmpty {\n            dict[.city] = locality\n        } else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {\n            dict[.city] = subAdmin\n        } else if let admin = pm.administrativeArea, !admin.isEmpty {\n            dict[.city] = admin\n        }\n        if let subLocality = pm.subLocality, !subLocality.isEmpty {\n            dict[.neighborhood] = subLocality\n        } else if let locality = pm.locality, !locality.isEmpty {\n            dict[.neighborhood] = locality\n        }\n        if let subLocality = pm.subLocality, !subLocality.isEmpty {\n            dict[.block] = subLocality\n        } else if let locality = pm.locality, !locality.isEmpty {\n            dict[.block] = locality\n        }\n        if let name = pm.name, !name.isEmpty {\n            dict[.building] = name\n        } else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {\n            dict[.building] = thoroughfare\n        }\n        return dict\n    }\n\n    func resolveBookmarkNameIfNeeded(for geohash: String) {\n        let gh = Self.normalizeGeohash(geohash)\n        guard !gh.isEmpty, bookmarkNames[gh] == nil, !resolvingNames.contains(gh) else { return }\n        resolvingNames.insert(gh)\n\n        if gh.count <= 2 {\n            let b = Geohash.decodeBounds(gh)\n            let pts: [CLLocation] = [\n                CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2),\n                CLLocation(latitude: b.latMin, longitude: b.lonMin),\n                CLLocation(latitude: b.latMin, longitude: b.lonMax),\n                CLLocation(latitude: b.latMax, longitude: b.lonMin),\n                CLLocation(latitude: b.latMax, longitude: b.lonMax)\n            ]\n            resolveCompositeAdminName(geohash: gh, points: pts)\n        } else {\n            let center = Geohash.decodeCenter(gh)\n            let loc = CLLocation(latitude: center.lat, longitude: center.lon)\n            geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in\n                guard let self = self else { return }\n                defer { self.resolvingNames.remove(gh) }\n                if let pm = placemarks?.first,\n                   let name = Self.nameForGeohashLength(gh.count, from: pm),\n                   !name.isEmpty {\n                    DispatchQueue.main.async {\n                        self.bookmarkNames[gh] = name\n                        self.persistBookmarkNames()\n                    }\n                }\n            }\n        }\n    }\n\n    private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {\n        var uniqueAdmins: [String] = []\n        var seenAdmins = Set<String>()\n        var idx = 0\n\n        func step() {\n            if idx >= points.count {\n                let finalName: String? = {\n                    if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + \" and \" + uniqueAdmins[1] }\n                    return uniqueAdmins.first\n                }()\n                if let finalName = finalName, !finalName.isEmpty {\n                    DispatchQueue.main.async {\n                        self.bookmarkNames[gh] = finalName\n                        self.persistBookmarkNames()\n                    }\n                }\n                self.resolvingNames.remove(gh)\n                return\n            }\n            let loc = points[idx]\n            idx += 1\n            geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in\n                guard self != nil else { return }\n                if let pm = placemarks?.first {\n                    if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) {\n                        seenAdmins.insert(admin)\n                        uniqueAdmins.append(admin)\n                    } else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) {\n                        seenAdmins.insert(country)\n                        uniqueAdmins.append(country)\n                    }\n                }\n                step()\n            }\n        }\n        step()\n    }\n\n    private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {\n        switch len {\n        case 0...2:\n            return pm.administrativeArea ?? pm.country\n        case 3...4:\n            return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country\n        case 5:\n            return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea\n        case 6...7:\n            return pm.subLocality ?? pm.locality ?? pm.administrativeArea\n        default:\n            return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country\n        }\n    }\n\n    // MARK: - Private Helpers (Persistence)\n\n    private func persistTeleportedSet() {\n        if let data = try? JSONEncoder().encode(Array(teleportedSet)) {\n            storage.set(data, forKey: teleportedStoreKey)\n        }\n    }\n\n    private func persistBookmarks() {\n        if let data = try? JSONEncoder().encode(bookmarks) {\n            storage.set(data, forKey: bookmarksKey)\n        }\n    }\n\n    private func persistBookmarkNames() {\n        if let data = try? JSONEncoder().encode(bookmarkNames) {\n            storage.set(data, forKey: bookmarkNamesKey)\n        }\n    }\n\n    private static func normalizeGeohash(_ s: String) -> String {\n        let allowed = Set(\"0123456789bcdefghjkmnpqrstuvwxyz\")\n        return s\n            .trimmingCharacters(in: .whitespacesAndNewlines)\n            .lowercased()\n            .replacingOccurrences(of: \"#\", with: \"\")\n            .filter { allowed.contains($0) }\n    }\n}\n\n// MARK: - Backward Compatibility Typealiases\n\ntypealias LocationChannelManager = LocationStateManager\ntypealias GeohashBookmarksStore = LocationStateManager\n\n// MARK: - Backward Compatibility Extensions\n\nextension LocationStateManager {\n    /// Backward compatibility: toggle bookmark (was GeohashBookmarksStore.toggle)\n    func toggle(_ geohash: String) {\n        toggleBookmark(geohash)\n    }\n\n    /// Backward compatibility: add bookmark (was GeohashBookmarksStore.add)\n    func add(_ geohash: String) {\n        addBookmark(geohash)\n    }\n\n    /// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove)\n    func remove(_ geohash: String) {\n        removeBookmark(geohash)\n    }\n}\n#endif\n"
  },
  {
    "path": "bitchat/Services/MeshTopologyTracker.swift",
    "content": "import Foundation\n\n/// Tracks observed mesh topology and computes hop-by-hop routes.\nfinal class MeshTopologyTracker {\n    private typealias RoutingID = Data\n\n    private let queue = DispatchQueue(label: \"mesh.topology\", attributes: .concurrent)\n    private let hopSize = 8\n    // Directed claims: Key claims to see Value (neighbors)\n    private var claims: [RoutingID: Set<RoutingID>] = [:]\n    // Last time we received an update from a node\n    private var lastSeen: [RoutingID: Date] = [:]\n\n    // Maximum age for topology claims to be considered fresh for routing\n    // Routes computed using stale topology can fail when the network has changed\n    private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds\n\n    func reset() {\n        queue.sync(flags: .barrier) {\n            self.claims.removeAll()\n            self.lastSeen.removeAll()\n        }\n    }\n\n    /// Update the topology with a node's self-reported neighbor list\n    func updateNeighbors(for sourceData: Data?, neighbors: [Data]) {\n        guard let source = sanitize(sourceData) else { return }\n        // Sanitize neighbors and exclude self-loops\n        let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])\n        \n        queue.sync(flags: .barrier) {\n            self.claims[source] = validNeighbors\n            self.lastSeen[source] = Date()\n        }\n    }\n\n    func removePeer(_ data: Data?) {\n        guard let peer = sanitize(data) else { return }\n        queue.sync(flags: .barrier) {\n            self.claims.removeValue(forKey: peer)\n            self.lastSeen.removeValue(forKey: peer)\n        }\n    }\n    \n    /// Prune nodes that haven't updated their topology in `age` seconds\n    func prune(olderThan age: TimeInterval) {\n        let deadline = Date().addingTimeInterval(-age)\n        queue.sync(flags: .barrier) {\n            let stale = self.lastSeen.filter { $0.value < deadline }\n            for (peer, _) in stale {\n                self.claims.removeValue(forKey: peer)\n                self.lastSeen.removeValue(forKey: peer)\n            }\n        }\n    }\n\n    func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {\n        guard let source = sanitize(start), let target = sanitize(goal) else { return nil }\n        if source == target { return [] } // Direct connection, no intermediate hops\n\n        return queue.sync {\n            let now = Date()\n            let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)\n\n            // BFS\n            var visited: Set<RoutingID> = [source]\n            // Queue stores paths: [Start, Hop1, Hop2, ..., Current]\n            var queuePaths: [[RoutingID]] = [[source]]\n\n            while !queuePaths.isEmpty {\n                let path = queuePaths.removeFirst()\n                // Limit path length (path contains source + maxHops + target) -> maxHops intermediate\n                // If maxHops = 10, max edges = 11, max nodes = 12.\n                if path.count > maxHops + 1 { continue }\n\n                guard let last = path.last else { continue }\n\n                // Get neighbors that 'last' claims to see\n                guard let neighbors = claims[last] else { continue }\n\n                // Check if 'last' node's topology info is fresh\n                guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else {\n                    continue // Skip stale nodes\n                }\n\n                for neighbor in neighbors {\n                    if visited.contains(neighbor) { continue }\n\n                    // CONFIRMED EDGE CHECK:\n                    // 'last' claims 'neighbor' (checked above)\n                    // Does 'neighbor' claim 'last'?\n                    guard let neighborClaims = claims[neighbor],\n                          neighborClaims.contains(last) else {\n                        continue\n                    }\n\n                    // Check if 'neighbor' node's topology info is fresh\n                    guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else {\n                        continue // Skip edges to stale nodes\n                    }\n\n                    var nextPath = path\n                    nextPath.append(neighbor)\n\n                    if neighbor == target {\n                        // Return only intermediate hops\n                        // Path: [Source, I1, I2, Target] -> [I1, I2]\n                        return Array(nextPath.dropFirst().dropLast())\n                    }\n\n                    visited.insert(neighbor)\n                    queuePaths.append(nextPath)\n                }\n            }\n            return nil\n        }\n    }\n\n    // MARK: - Helpers\n\n    private func sanitize(_ data: Data?) -> Data? {\n        guard var value = data, !value.isEmpty else { return nil }\n        if value.count > hopSize {\n            value = Data(value.prefix(hopSize))\n        } else if value.count < hopSize {\n            value.append(Data(repeating: 0, count: hopSize - value.count))\n        }\n        return value\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/MessageDeduplicationService.swift",
    "content": "//\n// MessageDeduplicationService.swift\n// bitchat\n//\n// Handles message deduplication using LRU caches.\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Foundation\n\n// MARK: - LRU Deduplication Cache\n\n/// Generic LRU (Least Recently Used) cache for deduplication.\n/// Uses an efficient O(1) lookup with periodic compaction.\n/// Thread-safe via @MainActor - all callers are already on main actor.\n@MainActor\nfinal class LRUDeduplicationCache<Value> {\n    private var map: [String: Value] = [:]\n    private var order: [String] = []\n    private var head: Int = 0\n    private let capacity: Int\n\n    /// Creates a new LRU cache with the specified capacity.\n    /// - Parameter capacity: Maximum number of entries before eviction\n    init(capacity: Int) {\n        precondition(capacity > 0, \"LRU cache capacity must be positive\")\n        self.capacity = capacity\n    }\n\n    /// Number of active entries in the cache\n    var count: Int {\n        order.count - head\n    }\n\n    /// Checks if a key exists in the cache\n    func contains(_ key: String) -> Bool {\n        map[key] != nil\n    }\n\n    /// Gets the value for a key, or nil if not present\n    func value(for key: String) -> Value? {\n        map[key]\n    }\n\n    /// Records a key-value pair, updating if exists or inserting if new\n    func record(_ key: String, value: Value) {\n        if map[key] == nil {\n            order.append(key)\n        }\n        map[key] = value\n        trimIfNeeded()\n    }\n\n    /// Removes a specific key from the cache\n    func remove(_ key: String) {\n        map.removeValue(forKey: key)\n        // Note: key remains in order array but will be skipped during eviction\n    }\n\n    /// Clears all entries from the cache\n    func clear() {\n        map.removeAll()\n        order.removeAll()\n        head = 0\n    }\n\n    // MARK: - Private\n\n    private func trimIfNeeded() {\n        let activeCount = order.count - head\n        guard activeCount > capacity else { return }\n\n        let overflow = activeCount - capacity\n        for _ in 0..<overflow {\n            guard let victim = popOldest() else { break }\n            map.removeValue(forKey: victim)\n        }\n    }\n\n    private func popOldest() -> String? {\n        // Skip keys that were already removed from map\n        while head < order.count {\n            let key = order[head]\n            head += 1\n\n            // Periodically compact the backing storage\n            if head >= 32 && head * 2 >= order.count {\n                order.removeFirst(head)\n                head = 0\n            }\n\n            // Only return if key is still in map\n            if map[key] != nil {\n                return key\n            }\n        }\n        return nil\n    }\n}\n\n// MARK: - Content Normalizer\n\n/// Normalizes message content for near-duplicate detection.\nenum ContentNormalizer {\n\n    /// Regex to simplify HTTP URLs by stripping query strings and fragments\n    private static let simplifyHTTPURL: NSRegularExpression = {\n        try! NSRegularExpression(\n            pattern: \"https?://[^\\\\s?#]+(?:[?#][^\\\\s]*)?\",\n            options: [.caseInsensitive]\n        )\n    }()\n\n    /// Normalizes content for deduplication comparison.\n    /// - Parameters:\n    ///   - content: The raw message content\n    ///   - prefixLength: Maximum characters to consider (default from TransportConfig)\n    /// - Returns: A hash-based key for comparison\n    static func normalizedKey(\n        _ content: String,\n        prefixLength: Int = TransportConfig.contentKeyPrefixLength\n    ) -> String {\n        // Lowercase for case-insensitive comparison\n        let lowered = content.lowercased()\n        let ns = lowered as NSString\n        let range = NSRange(location: 0, length: ns.length)\n\n        // Simplify URLs by stripping query/fragment\n        var simplified = \"\"\n        var last = 0\n        for match in simplifyHTTPURL.matches(in: lowered, options: [], range: range) {\n            if match.range.location > last {\n                simplified += ns.substring(with: NSRange(location: last, length: match.range.location - last))\n            }\n            let url = ns.substring(with: match.range)\n            if let queryIndex = url.firstIndex(where: { $0 == \"?\" || $0 == \"#\" }) {\n                simplified += String(url[..<queryIndex])\n            } else {\n                simplified += url\n            }\n            last = match.range.location + match.range.length\n        }\n        if last < ns.length {\n            simplified += ns.substring(with: NSRange(location: last, length: ns.length - last))\n        }\n\n        // Trim and collapse whitespace\n        let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)\n        let collapsed = trimmed.replacingOccurrences(of: \"\\\\s+\", with: \" \", options: .regularExpression)\n\n        // Take prefix and hash\n        let prefix = String(collapsed.prefix(prefixLength))\n        let hash = prefix.djb2()\n        return String(format: \"h:%016llx\", hash)\n    }\n}\n\n// MARK: - Message Deduplication Service\n\n/// Service that manages message deduplication using LRU caches.\n/// Provides separate caches for content-based dedup and Nostr event ID dedup.\n/// Thread-safe via @MainActor - all callers are already on main actor.\n@MainActor\nfinal class MessageDeduplicationService {\n\n    /// Cache for content-based near-duplicate detection\n    private let contentCache: LRUDeduplicationCache<Date>\n\n    /// Cache for Nostr event ID deduplication\n    private let nostrEventCache: LRUDeduplicationCache<Bool>\n\n    /// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)\n    private let nostrAckCache: LRUDeduplicationCache<Bool>\n\n    /// Creates a new deduplication service with specified capacities.\n    /// - Parameters:\n    ///   - contentCapacity: Max entries for content cache\n    ///   - nostrEventCapacity: Max entries for Nostr event cache\n    init(\n        contentCapacity: Int = TransportConfig.contentLRUCap,\n        nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap\n    ) {\n        self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)\n        self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity)\n        self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity)\n    }\n\n    // MARK: - Content Deduplication\n\n    /// Records content with its timestamp for near-duplicate detection.\n    /// - Parameters:\n    ///   - content: The message content\n    ///   - timestamp: When the content was received\n    func recordContent(_ content: String, timestamp: Date) {\n        let key = ContentNormalizer.normalizedKey(content)\n        contentCache.record(key, value: timestamp)\n    }\n\n    /// Records a pre-normalized content key with its timestamp.\n    /// - Parameters:\n    ///   - key: The normalized content key\n    ///   - timestamp: When the content was received\n    func recordContentKey(_ key: String, timestamp: Date) {\n        contentCache.record(key, value: timestamp)\n    }\n\n    /// Gets the timestamp for previously seen content.\n    /// - Parameter content: The message content\n    /// - Returns: The timestamp when first seen, or nil if not seen\n    func contentTimestamp(for content: String) -> Date? {\n        let key = ContentNormalizer.normalizedKey(content)\n        return contentCache.value(for: key)\n    }\n\n    /// Gets the timestamp for a pre-normalized content key.\n    /// - Parameter key: The normalized content key\n    /// - Returns: The timestamp when first seen, or nil if not seen\n    func contentTimestamp(forKey key: String) -> Date? {\n        contentCache.value(for: key)\n    }\n\n    /// Normalizes content to a deduplication key.\n    /// - Parameter content: The raw content\n    /// - Returns: A normalized hash key\n    func normalizedContentKey(_ content: String) -> String {\n        ContentNormalizer.normalizedKey(content)\n    }\n\n    // MARK: - Nostr Event Deduplication\n\n    /// Checks if a Nostr event has already been processed.\n    /// - Parameter eventId: The event ID\n    /// - Returns: true if already processed\n    func hasProcessedNostrEvent(_ eventId: String) -> Bool {\n        nostrEventCache.contains(eventId)\n    }\n\n    /// Records a Nostr event as processed.\n    /// - Parameter eventId: The event ID\n    func recordNostrEvent(_ eventId: String) {\n        nostrEventCache.record(eventId, value: true)\n    }\n\n    // MARK: - Nostr ACK Deduplication\n\n    /// Checks if a Nostr ACK has already been processed.\n    /// - Parameter ackKey: The ACK key in format \"messageId:ackType:senderPubkey\"\n    /// - Returns: true if already processed\n    func hasProcessedNostrAck(_ ackKey: String) -> Bool {\n        nostrAckCache.contains(ackKey)\n    }\n\n    /// Records a Nostr ACK as processed.\n    /// - Parameter ackKey: The ACK key in format \"messageId:ackType:senderPubkey\"\n    func recordNostrAck(_ ackKey: String) {\n        nostrAckCache.record(ackKey, value: true)\n    }\n\n    /// Creates an ACK key from components.\n    static func ackKey(messageId: String, ackType: String, senderPubkey: String) -> String {\n        \"\\(messageId):\\(ackType):\\(senderPubkey)\"\n    }\n\n    // MARK: - Clear\n\n    /// Clears all caches\n    func clearAll() {\n        contentCache.clear()\n        nostrEventCache.clear()\n        nostrAckCache.clear()\n    }\n\n    /// Clears only the Nostr caches (events and ACKs)\n    func clearNostrCaches() {\n        nostrEventCache.clear()\n        nostrAckCache.clear()\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/MessageFormattingEngine.swift",
    "content": "//\n// MessageFormattingEngine.swift\n// bitchat\n//\n// Handles message text formatting, including mentions, hashtags, URLs, and tokens.\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Foundation\nimport SwiftUI\n\n// MARK: - Formatting Context Protocol\n\n/// Protocol defining the context needed for message formatting.\n/// Implemented by ChatViewModel to provide runtime state.\n@MainActor\nprotocol MessageFormattingContext: AnyObject {\n    /// The user's current nickname\n    var nickname: String { get }\n\n    /// Determines if a message was sent by the current user\n    func isSelfMessage(_ message: BitchatMessage) -> Bool\n\n    /// Gets the color for a message's sender\n    func senderColor(for message: BitchatMessage, isDark: Bool) -> Color\n\n    /// Resolves a peer ID to a clickable URL\n    func peerURL(for peerID: PeerID) -> URL?\n}\n\n// MARK: - Formatting Engine\n\n/// Handles rich text formatting for chat messages.\n/// Extracts mentions, hashtags, URLs, Lightning invoices, and Cashu tokens.\nfinal class MessageFormattingEngine {\n\n    // MARK: - Precompiled Regexes\n\n    /// Precompiled regex patterns for message content parsing\n    enum Patterns {\n        static let hashtag: NSRegularExpression = {\n            try! NSRegularExpression(pattern: \"#([a-zA-Z0-9_]+)\", options: [])\n        }()\n\n        static let mention: NSRegularExpression = {\n            try! NSRegularExpression(pattern: \"@([\\\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)\", options: [])\n        }()\n\n        static let cashu: NSRegularExpression = {\n            try! NSRegularExpression(pattern: \"\\\\bcashu[AB][A-Za-z0-9._-]{40,}\\\\b\", options: [])\n        }()\n\n        static let bolt11: NSRegularExpression = {\n            try! NSRegularExpression(pattern: \"(?i)\\\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\\\b\", options: [])\n        }()\n\n        static let lnurl: NSRegularExpression = {\n            try! NSRegularExpression(pattern: \"(?i)\\\\blnurl1[a-z0-9]{20,}\\\\b\", options: [])\n        }()\n\n        static let lightningScheme: NSRegularExpression = {\n            try! NSRegularExpression(pattern: \"(?i)\\\\blightning:[^\\\\s]+\", options: [])\n        }()\n\n        static let linkDetector: NSDataDetector? = {\n            try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)\n        }()\n\n        static let quickCashuPresence: NSRegularExpression = {\n            try! NSRegularExpression(pattern: \"\\\\bcashu[AB][A-Za-z0-9._-]{40,}\\\\b\", options: [])\n        }()\n\n        static let simplifyHTTPURL: NSRegularExpression = {\n            try! NSRegularExpression(pattern: \"https?://[^\\\\s?#]+(?:[?#][^\\\\s]*)?\", options: [.caseInsensitive])\n        }()\n    }\n\n    // MARK: - Match Types\n\n    /// Types of matches found in message content\n    enum MatchType: String {\n        case hashtag\n        case mention\n        case url\n        case cashu\n        case lightning\n        case bolt11\n        case lnurl\n    }\n\n    /// A match found in message content\n    struct ContentMatch {\n        let range: NSRange\n        let type: MatchType\n    }\n\n    // MARK: - Public API\n\n    /// Formats a message with rich text styling\n    @MainActor\n    static func formatMessage(\n        _ message: BitchatMessage,\n        context: MessageFormattingContext,\n        colorScheme: ColorScheme\n    ) -> AttributedString {\n        let isDark = colorScheme == .dark\n        let isSelf = context.isSelfMessage(message)\n\n        // Check cache first\n        if let cached = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {\n            return cached\n        }\n\n        var result = AttributedString()\n        let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)\n\n        // Format system messages differently\n        if message.sender == \"system\" {\n            result = formatSystemMessage(message, isDark: isDark)\n        } else {\n            // Format sender header\n            result = formatSenderHeader(\n                message: message,\n                baseColor: baseColor,\n                isSelf: isSelf,\n                context: context\n            )\n\n            // Format content\n            let contentResult = formatContent(\n                message.content,\n                baseColor: baseColor,\n                isSelf: isSelf,\n                isMentioned: message.mentions?.contains(context.nickname) ?? false\n            )\n            result.append(contentResult)\n\n            // Add timestamp\n            result.append(formatTimestamp(message.formattedTimestamp))\n        }\n\n        // Cache the result\n        message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)\n\n        return result\n    }\n\n    /// Formats just the message header (sender portion)\n    @MainActor\n    static func formatHeader(\n        _ message: BitchatMessage,\n        context: MessageFormattingContext,\n        colorScheme: ColorScheme\n    ) -> AttributedString {\n        let isDark = colorScheme == .dark\n        let isSelf = context.isSelfMessage(message)\n        let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)\n\n        if message.sender == \"system\" {\n            var style = AttributeContainer()\n            style.foregroundColor = baseColor\n            style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)\n            return AttributedString(message.sender).mergingAttributes(style)\n        }\n\n        return formatSenderHeader(\n            message: message,\n            baseColor: baseColor,\n            isSelf: isSelf,\n            context: context\n        )\n    }\n\n    /// Extracts mentions from message content\n    static func extractMentions(from content: String) -> [String] {\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        let matches = Patterns.mention.matches(in: content, options: [], range: range)\n\n        return matches.compactMap { match -> String? in\n            guard match.numberOfRanges > 1 else { return nil }\n            let captureRange = match.range(at: 1)\n            guard let swiftRange = Range(captureRange, in: content) else { return nil }\n            return String(content[swiftRange])\n        }\n    }\n\n    /// Checks if content contains a Cashu token\n    static func containsCashuToken(_ content: String) -> Bool {\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        return Patterns.quickCashuPresence.numberOfMatches(in: content, options: [], range: range) > 0\n    }\n\n    // MARK: - Private Helpers\n\n    private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString {\n        var result = AttributedString()\n\n        let content = AttributedString(\"* \\(message.content) *\")\n        var contentStyle = AttributeContainer()\n        contentStyle.foregroundColor = Color.gray\n        contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()\n        result.append(content.mergingAttributes(contentStyle))\n\n        // Add timestamp\n        let timestamp = AttributedString(\" [\\(message.formattedTimestamp)]\")\n        var timestampStyle = AttributeContainer()\n        timestampStyle.foregroundColor = Color.gray.opacity(0.5)\n        timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)\n        result.append(timestamp.mergingAttributes(timestampStyle))\n\n        return result\n    }\n\n    @MainActor\n    private static func formatSenderHeader(\n        message: BitchatMessage,\n        baseColor: Color,\n        isSelf: Bool,\n        context: MessageFormattingContext\n    ) -> AttributedString {\n        var result = AttributedString()\n\n        let (baseName, suffix) = message.sender.splitSuffix()\n        var senderStyle = AttributeContainer()\n        senderStyle.foregroundColor = baseColor\n        let fontWeight: Font.Weight = isSelf ? .bold : .medium\n        senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)\n\n        // Make sender clickable\n        if let spid = message.senderPeerID, let url = context.peerURL(for: spid) {\n            senderStyle.link = url\n        }\n\n        // Build: \"<@baseName#suffix> \"\n        result.append(AttributedString(\"<@\").mergingAttributes(senderStyle))\n        result.append(AttributedString(baseName).mergingAttributes(senderStyle))\n\n        if !suffix.isEmpty {\n            var suffixStyle = senderStyle\n            suffixStyle.foregroundColor = baseColor.opacity(0.6)\n            result.append(AttributedString(suffix).mergingAttributes(suffixStyle))\n        }\n\n        result.append(AttributedString(\"> \").mergingAttributes(senderStyle))\n\n        return result\n    }\n\n    private static func formatContent(\n        _ content: String,\n        baseColor: Color,\n        isSelf: Bool,\n        isMentioned: Bool\n    ) -> AttributedString {\n        // For very long content without special tokens, use plain formatting\n        let containsCashu = containsCashuToken(content)\n        if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {\n            return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)\n        }\n\n        // Find all matches\n        let matches = findAllMatches(in: content)\n\n        // Build formatted content\n        var result = AttributedString()\n        var lastEnd = content.startIndex\n\n        for match in matches {\n            guard let swiftRange = Range(match.range, in: content) else { continue }\n\n            // Add text before match\n            if lastEnd < swiftRange.lowerBound {\n                let beforeText = String(content[lastEnd..<swiftRange.lowerBound])\n                result.append(formatPlainText(beforeText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))\n            }\n\n            // Add styled match\n            let matchText = String(content[swiftRange])\n            result.append(formatMatch(matchText, type: match.type, baseColor: baseColor, isSelf: isSelf))\n\n            lastEnd = swiftRange.upperBound\n        }\n\n        // Add remaining text\n        if lastEnd < content.endIndex {\n            let remainingText = String(content[lastEnd...])\n            result.append(formatPlainText(remainingText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))\n        }\n\n        return result\n    }\n\n    private static func findAllMatches(in content: String) -> [ContentMatch] {\n        let nsContent = content as NSString\n        let nsLen = nsContent.length\n        let fullRange = NSRange(location: 0, length: nsLen)\n\n        // Quick hints to avoid unnecessary regex work\n        let hasMentions = content.contains(\"@\")\n        let hasHashtags = content.contains(\"#\")\n        let hasURLs = content.contains(\"://\") || content.contains(\"www.\") || content.contains(\"http\")\n        let hasLightning = content.lowercased().contains(\"ln\") || content.lowercased().contains(\"lightning:\")\n        let hasCashu = content.lowercased().contains(\"cashu\")\n\n        // Collect matches\n        let mentionMatches = hasMentions ? Patterns.mention.matches(in: content, options: [], range: fullRange) : []\n        let hashtagMatches = hasHashtags ? Patterns.hashtag.matches(in: content, options: [], range: fullRange) : []\n        let urlMatches = hasURLs ? (Patterns.linkDetector?.matches(in: content, options: [], range: fullRange) ?? []) : []\n        let cashuMatches = hasCashu ? Patterns.cashu.matches(in: content, options: [], range: fullRange) : []\n        let lightningMatches = hasLightning ? Patterns.lightningScheme.matches(in: content, options: [], range: fullRange) : []\n        let bolt11Matches = hasLightning ? Patterns.bolt11.matches(in: content, options: [], range: fullRange) : []\n        let lnurlMatches = hasLightning ? Patterns.lnurl.matches(in: content, options: [], range: fullRange) : []\n\n        // Build mention ranges for overlap checking\n        let mentionRanges = mentionMatches.map { $0.range(at: 0) }\n\n        func overlapsMention(_ r: NSRange) -> Bool {\n            mentionRanges.contains { NSIntersectionRange(r, $0).length > 0 }\n        }\n\n        func isStandaloneHashtag(_ r: NSRange) -> Bool {\n            guard let swiftRange = Range(r, in: content) else { return false }\n            if swiftRange.lowerBound == content.startIndex { return true }\n            let prev = content.index(before: swiftRange.lowerBound)\n            return content[prev].isWhitespace || content[prev].isNewline\n        }\n\n        func attachedToMention(_ r: NSRange) -> Bool {\n            guard let swiftRange = Range(r, in: content), swiftRange.lowerBound > content.startIndex else { return false }\n            var i = content.index(before: swiftRange.lowerBound)\n            while true {\n                let ch = content[i]\n                if ch.isWhitespace || ch.isNewline { break }\n                if ch == \"@\" { return true }\n                if i == content.startIndex { break }\n                i = content.index(before: i)\n            }\n            return false\n        }\n\n        var allMatches: [ContentMatch] = []\n\n        // Add hashtags (excluding those attached to mentions)\n        for match in hashtagMatches {\n            let range = match.range(at: 0)\n            if !overlapsMention(range) && !attachedToMention(range) && isStandaloneHashtag(range) {\n                allMatches.append(ContentMatch(range: range, type: .hashtag))\n            }\n        }\n\n        // Add mentions\n        for match in mentionMatches {\n            allMatches.append(ContentMatch(range: match.range(at: 0), type: .mention))\n        }\n\n        // Add URLs\n        for match in urlMatches where !overlapsMention(match.range) {\n            allMatches.append(ContentMatch(range: match.range, type: .url))\n        }\n\n        // Add Cashu tokens\n        for match in cashuMatches where !overlapsMention(match.range(at: 0)) {\n            allMatches.append(ContentMatch(range: match.range(at: 0), type: .cashu))\n        }\n\n        // Add Lightning scheme URLs\n        for match in lightningMatches where !overlapsMention(match.range(at: 0)) {\n            allMatches.append(ContentMatch(range: match.range(at: 0), type: .lightning))\n        }\n\n        // Add bolt11/lnurl (avoiding overlaps with lightning scheme and URLs)\n        let occupied = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }\n        func overlapsOccupied(_ r: NSRange) -> Bool {\n            occupied.contains { NSIntersectionRange(r, $0).length > 0 }\n        }\n\n        for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {\n            allMatches.append(ContentMatch(range: match.range(at: 0), type: .bolt11))\n        }\n\n        for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {\n            allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl))\n        }\n\n        // Sort by position\n        return allMatches.sorted { $0.range.location < $1.range.location }\n    }\n\n    private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString {\n        var style = AttributeContainer()\n        style.foregroundColor = baseColor\n        style.font = isSelf\n            ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)\n            : .bitchatSystem(size: 14, design: .monospaced)\n        return AttributedString(content).mergingAttributes(style)\n    }\n\n    private static func formatPlainText(_ text: String, baseColor: Color, isSelf: Bool, isMentioned: Bool) -> AttributedString {\n        guard !text.isEmpty else { return AttributedString() }\n\n        var style = AttributeContainer()\n        style.foregroundColor = baseColor\n        style.font = isSelf\n            ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)\n            : .bitchatSystem(size: 14, design: .monospaced)\n\n        if isMentioned {\n            style.font = style.font?.bold()\n        }\n\n        return AttributedString(text).mergingAttributes(style)\n    }\n\n    private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString {\n        var style = AttributeContainer()\n\n        switch type {\n        case .mention:\n            // Split optional '#abcd' suffix\n            let (baseName, suffix) = text.splitSuffix()\n            var result = AttributedString()\n\n            var mentionStyle = AttributeContainer()\n            mentionStyle.foregroundColor = .blue\n            mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)\n            result.append(AttributedString(baseName).mergingAttributes(mentionStyle))\n\n            if !suffix.isEmpty {\n                var suffixStyle = mentionStyle\n                suffixStyle.foregroundColor = Color.gray.opacity(0.7)\n                result.append(AttributedString(suffix).mergingAttributes(suffixStyle))\n            }\n\n            return result\n\n        case .hashtag:\n            style.foregroundColor = .purple\n            style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)\n\n        case .url:\n            style.foregroundColor = .blue\n            style.font = .bitchatSystem(size: 14, design: .monospaced)\n            style.underlineStyle = .single\n            if let url = URL(string: text) {\n                style.link = url\n            }\n\n        case .cashu:\n            style.foregroundColor = .green\n            style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)\n            style.backgroundColor = Color.green.opacity(0.1)\n\n        case .lightning, .bolt11, .lnurl:\n            style.foregroundColor = .yellow\n            style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)\n            style.backgroundColor = Color.yellow.opacity(0.1)\n        }\n\n        return AttributedString(text).mergingAttributes(style)\n    }\n\n    private static func formatTimestamp(_ timestamp: String) -> AttributedString {\n        let text = AttributedString(\" [\\(timestamp)]\")\n        var style = AttributeContainer()\n        style.foregroundColor = Color.gray.opacity(0.5)\n        style.font = .bitchatSystem(size: 10, design: .monospaced)\n        return text.mergingAttributes(style)\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/MessageRouter.swift",
    "content": "import BitLogger\nimport Foundation\n\n/// Routes messages using available transports (Mesh, Nostr, etc.)\n@MainActor\nfinal class MessageRouter {\n    private let transports: [Transport]\n\n    // Outbox entry with timestamp for TTL-based eviction\n    private struct QueuedMessage {\n        let content: String\n        let nickname: String\n        let messageID: String\n        let timestamp: Date\n    }\n\n    private var outbox: [PeerID: [QueuedMessage]] = [:]\n\n    // Outbox limits to prevent unbounded memory growth\n    private static let maxMessagesPerPeer = 100\n    private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours\n\n    init(transports: [Transport]) {\n        self.transports = transports\n\n        // Observe favorites changes to learn Nostr mapping and flush queued messages\n        NotificationCenter.default.addObserver(\n            forName: .favoriteStatusChanged,\n            object: nil,\n            queue: .main\n        ) { [weak self] note in\n            guard let self = self else { return }\n            if let data = note.userInfo?[\"peerPublicKey\"] as? Data {\n                let peerID = PeerID(publicKey: data)\n                Task { @MainActor in\n                    self.flushOutbox(for: peerID)\n                }\n            }\n            // Handle key updates\n            if let newKey = note.userInfo?[\"peerPublicKey\"] as? Data,\n               let _ = note.userInfo?[\"isKeyUpdate\"] as? Bool {\n                let peerID = PeerID(publicKey: newKey)\n                Task { @MainActor in\n                    self.flushOutbox(for: peerID)\n                }\n            }\n        }\n    }\n\n    // MARK: - Transport Selection\n\n    private func reachableTransport(for peerID: PeerID) -> Transport? {\n        transports.first { $0.isPeerReachable(peerID) }\n    }\n\n    private func connectedTransport(for peerID: PeerID) -> Transport? {\n        transports.first { $0.isPeerConnected(peerID) }\n    }\n\n    // MARK: - Message Sending\n\n    func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {\n        if let transport = reachableTransport(for: peerID) {\n            SecureLogger.debug(\"Routing PM via \\(type(of: transport)) to \\(peerID.id.prefix(8))… id=\\(messageID.prefix(8))…\", category: .session)\n            transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)\n        } else {\n            // Queue for later with timestamp for TTL tracking\n            if outbox[peerID] == nil { outbox[peerID] = [] }\n\n            let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())\n            outbox[peerID]?.append(message)\n\n            // Enforce per-peer size limit with FIFO eviction\n            if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {\n                let evicted = outbox[peerID]?.removeFirst()\n                SecureLogger.warning(\"📤 Outbox overflow for \\(peerID.id.prefix(8))… - evicted oldest message: \\(evicted?.messageID.prefix(8) ?? \"?\")…\", category: .session)\n            }\n\n            SecureLogger.debug(\"Queued PM for \\(peerID.id.prefix(8))… (no reachable transport) id=\\(messageID.prefix(8))… queue=\\(outbox[peerID]?.count ?? 0)\", category: .session)\n        }\n    }\n\n    func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {\n        if let transport = reachableTransport(for: peerID) {\n            SecureLogger.debug(\"Routing READ ack via \\(type(of: transport)) to \\(peerID.id.prefix(8))… id=\\(receipt.originalMessageID.prefix(8))…\", category: .session)\n            transport.sendReadReceipt(receipt, to: peerID)\n        } else if !transports.isEmpty {\n            SecureLogger.debug(\"No reachable transport for READ ack to \\(peerID.id.prefix(8))…\", category: .session)\n        }\n    }\n\n    func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {\n        if let transport = reachableTransport(for: peerID) {\n            SecureLogger.debug(\"Routing DELIVERED ack via \\(type(of: transport)) to \\(peerID.id.prefix(8))… id=\\(messageID.prefix(8))…\", category: .session)\n            transport.sendDeliveryAck(for: messageID, to: peerID)\n        }\n    }\n\n    func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {\n        if let transport = connectedTransport(for: peerID) {\n            transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)\n        } else if let transport = reachableTransport(for: peerID) {\n            transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)\n        }\n    }\n\n    // MARK: - Outbox Management\n\n    func flushOutbox(for peerID: PeerID) {\n        guard let queued = outbox[peerID], !queued.isEmpty else { return }\n        SecureLogger.debug(\"Flushing outbox for \\(peerID.id.prefix(8))… count=\\(queued.count)\", category: .session)\n\n        let now = Date()\n        var remaining: [QueuedMessage] = []\n\n        for message in queued {\n            // Skip expired messages (TTL exceeded)\n            if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {\n                SecureLogger.debug(\"⏰ Expired queued message for \\(peerID.id.prefix(8))… id=\\(message.messageID.prefix(8))… (age: \\(Int(now.timeIntervalSince(message.timestamp)))s)\", category: .session)\n                continue\n            }\n\n            if let transport = reachableTransport(for: peerID) {\n                SecureLogger.debug(\"Outbox -> \\(type(of: transport)) for \\(peerID.id.prefix(8))… id=\\(message.messageID.prefix(8))…\", category: .session)\n                transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)\n            } else {\n                remaining.append(message)\n            }\n        }\n\n        if remaining.isEmpty {\n            outbox.removeValue(forKey: peerID)\n        } else {\n            outbox[peerID] = remaining\n        }\n    }\n\n    func flushAllOutbox() {\n        for key in Array(outbox.keys) { flushOutbox(for: key) }\n    }\n\n    /// Periodically clean up expired messages from all outboxes\n    func cleanupExpiredMessages() {\n        let now = Date()\n        for peerID in Array(outbox.keys) {\n            outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }\n            if outbox[peerID]?.isEmpty == true {\n                outbox.removeValue(forKey: peerID)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/NetworkActivationService.swift",
    "content": "import Foundation\nimport BitLogger\nimport Combine\nimport Tor\n\n@MainActor\nprotocol NetworkActivationTorControlling: AnyObject {\n    func setAutoStartAllowed(_ allowed: Bool)\n    func startIfNeeded()\n    func shutdownCompletely()\n}\n\n@MainActor\nprotocol NetworkActivationRelayControlling: AnyObject {\n    func connect()\n    func disconnect()\n}\n\nprotocol NetworkActivationProxyControlling: AnyObject {\n    func setProxyMode(useTor: Bool)\n}\n\nextension TorManager: NetworkActivationTorControlling {}\nextension NostrRelayManager: NetworkActivationRelayControlling {}\nextension TorURLSession: NetworkActivationProxyControlling {}\n\n/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.\n/// Policy: permit start when either location permissions are authorized OR\n/// there exists at least one mutual favorite. Otherwise, do not start.\n@MainActor\nfinal class NetworkActivationService: ObservableObject {\n    static let shared = NetworkActivationService()\n\n    @Published private(set) var activationAllowed: Bool = false\n    @Published private(set) var userTorEnabled: Bool = true\n\n    private var cancellables = Set<AnyCancellable>()\n    private var started = false\n    private let torPreferenceKey = \"networkActivationService.userTorEnabled\"\n    private var torAutoStartDesired: Bool = false\n    private let storage: UserDefaults\n    private let locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>\n    private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>\n    private let permissionProvider: () -> LocationChannelManager.PermissionState\n    private let mutualFavoritesProvider: () -> Set<Data>\n    private let torController: NetworkActivationTorControlling\n    private let relayController: NetworkActivationRelayControlling\n    private let proxyController: NetworkActivationProxyControlling\n    private let notificationCenter: NotificationCenter\n\n    private init() {\n        storage = .standard\n        locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher()\n        mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()\n        permissionProvider = { LocationChannelManager.shared.permissionState }\n        mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }\n        torController = TorManager.shared\n        relayController = NostrRelayManager.shared\n        proxyController = TorURLSession.shared\n        notificationCenter = .default\n    }\n\n    internal init(\n        storage: UserDefaults,\n        locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>,\n        mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,\n        permissionProvider: @escaping () -> LocationChannelManager.PermissionState,\n        mutualFavoritesProvider: @escaping () -> Set<Data>,\n        torController: NetworkActivationTorControlling,\n        relayController: NetworkActivationRelayControlling,\n        proxyController: NetworkActivationProxyControlling,\n        notificationCenter: NotificationCenter = .default\n    ) {\n        self.storage = storage\n        self.locationPermissionPublisher = locationPermissionPublisher\n        self.mutualFavoritesPublisher = mutualFavoritesPublisher\n        self.permissionProvider = permissionProvider\n        self.mutualFavoritesProvider = mutualFavoritesProvider\n        self.torController = torController\n        self.relayController = relayController\n        self.proxyController = proxyController\n        self.notificationCenter = notificationCenter\n    }\n\n    func start() {\n        guard !started else { return }\n        started = true\n\n        if let stored = storage.object(forKey: torPreferenceKey) as? Bool {\n            userTorEnabled = stored\n        } else {\n            userTorEnabled = true\n        }\n\n        // Initial compute\n        let allowed = basePolicyAllowed()\n        activationAllowed = allowed\n        torAutoStartDesired = allowed && userTorEnabled\n        torController.setAutoStartAllowed(torAutoStartDesired)\n        applyTorState(torDesired: torAutoStartDesired)\n        if allowed {\n            relayController.connect()\n        } else {\n            relayController.disconnect()\n        }\n\n        // React to location permission changes\n        locationPermissionPublisher\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] _ in\n                self?.reevaluate()\n            }\n            .store(in: &cancellables)\n\n        // React to mutual favorites changes\n        mutualFavoritesPublisher\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] _ in\n                self?.reevaluate()\n            }\n            .store(in: &cancellables)\n    }\n\n    func setUserTorEnabled(_ enabled: Bool) {\n        guard enabled != userTorEnabled else { return }\n        userTorEnabled = enabled\n        storage.set(enabled, forKey: torPreferenceKey)\n        notificationCenter.post(\n            name: .TorUserPreferenceChanged,\n            object: nil,\n            userInfo: [\"enabled\": enabled]\n        )\n        reevaluate()\n    }\n\n    private func reevaluate() {\n        let allowed = basePolicyAllowed()\n        let torDesired = allowed && userTorEnabled\n        let statusChanged = allowed != activationAllowed\n        let torChanged = torDesired != torAutoStartDesired\n        if statusChanged {\n            SecureLogger.info(\"NetworkActivationService: activationAllowed -> \\(allowed)\", category: .session)\n            activationAllowed = allowed\n        }\n        if statusChanged || torChanged {\n            torAutoStartDesired = torDesired\n            torController.setAutoStartAllowed(torDesired)\n            applyTorState(torDesired: torDesired)\n        }\n\n        if allowed {\n            if torChanged {\n                // Reset relay sockets when switching transport path (Tor ↔︎ direct)\n                relayController.disconnect()\n            }\n            relayController.connect()\n        } else if statusChanged {\n            relayController.disconnect()\n        }\n    }\n\n    private func basePolicyAllowed() -> Bool {\n        let permOK = permissionProvider() == .authorized\n        let hasMutual = !mutualFavoritesProvider().isEmpty\n        return permOK || hasMutual\n    }\n\n    private func applyTorState(torDesired: Bool) {\n        proxyController.setProxyMode(useTor: torDesired)\n        if torDesired {\n            torController.startIfNeeded()\n        } else {\n            torController.shutdownCompletely()\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/NoiseEncryptionService.swift",
    "content": "//\n// NoiseEncryptionService.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\n///\n/// # NoiseEncryptionService\n///\n/// High-level encryption service that manages Noise Protocol sessions for secure\n/// peer-to-peer communication in BitChat. Acts as the bridge between the transport\n/// layer (BLEService) and the cryptographic layer (NoiseProtocol).\n///\n/// ## Overview\n/// This service provides a simplified API for establishing and managing encrypted\n/// channels between peers. It handles:\n/// - Static identity key management\n/// - Session lifecycle (creation, maintenance, teardown)\n/// - Message encryption/decryption\n/// - Peer authentication and fingerprint tracking\n/// - Automatic rekeying for forward secrecy\n///\n/// ## Architecture\n/// The service operates at multiple levels:\n/// 1. **Identity Management**: Persistent Curve25519 keys stored in Keychain\n/// 2. **Session Management**: Per-peer Noise sessions with state tracking\n/// 3. **Message Processing**: Encryption/decryption with proper framing\n/// 4. **Security Features**: Rate limiting, fingerprint verification\n///\n/// ## Key Features\n///\n/// ### Identity Keys\n/// - Static Curve25519 key pair for Noise XX pattern\n/// - Ed25519 signing key pair for additional authentication\n/// - Keys persisted securely in iOS/macOS Keychain\n/// - Fingerprints derived from SHA256 of public keys\n///\n/// ### Session Management\n/// - Lazy session creation (on-demand when sending messages)\n/// - Automatic session recovery after disconnections\n/// - Configurable rekey intervals for forward secrecy\n/// - Graceful handling of simultaneous handshakes\n///\n/// ### Security Properties\n/// - Forward secrecy via ephemeral keys in handshakes\n/// - Mutual authentication via static key exchange\n/// - Protection against replay attacks\n/// - Rate limiting to prevent DoS attacks\n///\n/// ## Encryption Flow\n/// ```\n/// 1. Message arrives for encryption\n/// 2. Check if session exists for peer\n/// 3. If not, initiate Noise handshake\n/// 4. Once established, encrypt message\n/// 5. Add message type header for protocol handling\n/// 6. Return encrypted payload for transmission\n/// ```\n///\n/// ## Integration Points\n/// - **BLEService**: Calls this service for all private messages\n/// - **ChatViewModel**: Monitors encryption status for UI indicators\n/// - **KeychainManager**: Secure storage for identity keys\n///\n/// ## Thread Safety\n/// - Concurrent read access via reader-writer queue\n/// - Session operations protected by per-peer queues\n/// - Atomic updates for critical state changes\n///\n/// ## Error Handling\n/// - Graceful fallback for encryption failures\n/// - Clear error messages for debugging\n/// - Automatic retry with exponential backoff\n/// - User notification for critical failures\n///\n/// ## Performance Considerations\n/// - Sessions cached in memory for fast access\n/// - Minimal allocations in hot paths\n/// - Efficient binary message format\n/// - Background queue for CPU-intensive operations\n///\n\nimport BitLogger\nimport Foundation\nimport CryptoKit\n\n// MARK: - Encryption Status\n\n/// Represents the current encryption status of a peer connection.\n/// Used for UI indicators and decision-making about message handling.\nenum EncryptionStatus: Equatable {\n    case none                // Failed or incompatible\n    case noHandshake        // No handshake attempted yet\n    case noiseHandshaking   // Currently establishing\n    case noiseSecured       // Established but not verified\n    case noiseVerified      // Established and verified\n    \n    var icon: String? {  // Made optional to hide icon when no handshake\n        switch self {\n        case .none:\n            return \"lock.slash\"  // Failed handshake\n        case .noHandshake:\n            return nil  // No icon when no handshake attempted\n        case .noiseHandshaking:\n            return \"lock.rotation\"\n        case .noiseSecured:\n            return \"lock.fill\"  // Changed from \"lock\" to \"lock.fill\" for filled lock\n        case .noiseVerified:\n            return \"checkmark.seal.fill\"  // Verified badge\n        }\n    }\n    \n    var description: String {\n        switch self {\n        case .none:\n            return String(localized: \"encryption.status.failed\", comment: \"Status text when encryption failed\")\n        case .noHandshake:\n            return String(localized: \"encryption.status.not_encrypted\", comment: \"Status text when no encryption handshake happened\")\n        case .noiseHandshaking:\n            return String(localized: \"encryption.status.establishing\", comment: \"Status text when encryption is being established\")\n        case .noiseSecured:\n            return String(localized: \"encryption.status.secured\", comment: \"Status text when encryption is secured but not verified\")\n        case .noiseVerified:\n            return String(localized: \"encryption.status.verified\", comment: \"Status text when encryption is verified\")\n        }\n    }\n\n    var accessibilityDescription: String {\n        switch self {\n        case .none:\n            return String(localized: \"encryption.accessibility.failed\", comment: \"Accessibility text when encryption failed\")\n        case .noHandshake:\n            return String(localized: \"encryption.accessibility.not_encrypted\", comment: \"Accessibility text when encryption is not established\")\n        case .noiseHandshaking:\n            return String(localized: \"encryption.accessibility.establishing\", comment: \"Accessibility text when encryption is being established\")\n        case .noiseSecured:\n            return String(localized: \"encryption.accessibility.secured\", comment: \"Accessibility text when encryption is secured\")\n        case .noiseVerified:\n            return String(localized: \"encryption.accessibility.verified\", comment: \"Accessibility text when encryption is verified\")\n        }\n    }\n}\n\n// MARK: - Noise Encryption Service\n\n/// Manages end-to-end encryption for BitChat using the Noise Protocol Framework.\n/// Provides a high-level API for establishing secure channels between peers,\n/// handling all cryptographic operations transparently.\n/// - Important: This service maintains the device's cryptographic identity\nfinal class NoiseEncryptionService {\n    // Static identity key (persistent across sessions)\n    private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey\n    public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey\n    \n    // Ed25519 signing key (persistent across sessions)\n    private let signingKey: Curve25519.Signing.PrivateKey\n    public let signingPublicKey: Curve25519.Signing.PublicKey\n    \n    // Session manager\n    private let sessionManager: NoiseSessionManager\n    \n    // Peer fingerprints (SHA256 hash of static public key)\n    private var peerFingerprints: [PeerID: String] = [:]\n    private var fingerprintToPeerID: [String: PeerID] = [:]\n    \n    // Thread safety\n    private let serviceQueue = DispatchQueue(label: \"chat.bitchat.noise.service\", attributes: .concurrent)\n    \n    // Security components\n    private let rateLimiter = NoiseRateLimiter()\n    private let keychain: KeychainManagerProtocol\n    \n    // Session maintenance\n    private var rekeyTimer: Timer?\n    private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute\n    \n    // Callbacks\n    private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication\n    var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake\n    \n    // Add a handler for peer authentication\n    func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {\n        serviceQueue.async(flags: .barrier) { [weak self] in\n            self?.onPeerAuthenticatedHandlers.append(handler)\n        }\n    }\n    \n    // Legacy support - setting this will add to the handlers array\n    var onPeerAuthenticated: ((PeerID, String) -> Void)? {\n        get { nil } // Always return nil for backward compatibility\n        set {\n            if let handler = newValue {\n                addOnPeerAuthenticatedHandler(handler)\n            }\n        }\n    }\n    \n    init(keychain: KeychainManagerProtocol) {\n        self.keychain = keychain\n\n        // BCH-01-009: Load or create static identity key with proper error handling\n        let loadedKey: Curve25519.KeyAgreement.PrivateKey\n\n        // Try to load from keychain with proper error classification\n        let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: \"noiseStaticKey\")\n\n        switch noiseKeyResult {\n        case .success(let identityData):\n            if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {\n                loadedKey = key\n                SecureLogger.logKeyOperation(.load, keyType: \"noiseStaticKey\", success: true)\n            } else {\n                // Data corrupted, regenerate\n                SecureLogger.warning(\"Noise static key data corrupted, regenerating\", category: .keychain)\n                loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)\n            }\n\n        case .itemNotFound:\n            // Expected case: no key exists yet, create new one\n            loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)\n\n        case .accessDenied:\n            // Critical error - log but proceed with ephemeral key (will be lost on restart)\n            SecureLogger.error(NSError(domain: \"Keychain\", code: -1),\n                               context: \"Keychain access denied - using ephemeral identity\", category: .keychain)\n            loadedKey = Curve25519.KeyAgreement.PrivateKey()\n\n        case .deviceLocked, .authenticationFailed:\n            // Recoverable error - use ephemeral key and warn\n            SecureLogger.warning(\"Device locked or auth failed - using ephemeral identity until unlocked\", category: .keychain)\n            loadedKey = Curve25519.KeyAgreement.PrivateKey()\n\n        case .otherError(let status):\n            // Unexpected error - log and use ephemeral key\n            SecureLogger.error(NSError(domain: \"Keychain\", code: Int(status)),\n                               context: \"Unexpected keychain error - using ephemeral identity\", category: .keychain)\n            loadedKey = Curve25519.KeyAgreement.PrivateKey()\n        }\n\n        // Now assign the final value\n        self.staticIdentityKey = loadedKey\n        self.staticIdentityPublicKey = staticIdentityKey.publicKey\n\n        // BCH-01-009: Load or create signing key pair with proper error handling\n        let loadedSigningKey: Curve25519.Signing.PrivateKey\n\n        let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: \"ed25519SigningKey\")\n\n        switch signingKeyResult {\n        case .success(let signingData):\n            if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {\n                loadedSigningKey = key\n                SecureLogger.logKeyOperation(.load, keyType: \"ed25519SigningKey\", success: true)\n            } else {\n                // Data corrupted, regenerate\n                SecureLogger.warning(\"Ed25519 signing key data corrupted, regenerating\", category: .keychain)\n                loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)\n            }\n\n        case .itemNotFound:\n            // Expected case: no key exists yet, create new one\n            loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)\n\n        case .accessDenied:\n            // Critical error - log but proceed with ephemeral key\n            SecureLogger.error(NSError(domain: \"Keychain\", code: -1),\n                               context: \"Keychain access denied - using ephemeral signing key\", category: .keychain)\n            loadedSigningKey = Curve25519.Signing.PrivateKey()\n\n        case .deviceLocked, .authenticationFailed:\n            // Recoverable error - use ephemeral key and warn\n            SecureLogger.warning(\"Device locked or auth failed - using ephemeral signing key until unlocked\", category: .keychain)\n            loadedSigningKey = Curve25519.Signing.PrivateKey()\n\n        case .otherError(let status):\n            // Unexpected error - log and use ephemeral key\n            SecureLogger.error(NSError(domain: \"Keychain\", code: Int(status)),\n                               context: \"Unexpected keychain error - using ephemeral signing key\", category: .keychain)\n            loadedSigningKey = Curve25519.Signing.PrivateKey()\n        }\n\n        // Now assign the signing keys\n        self.signingKey = loadedSigningKey\n        self.signingPublicKey = signingKey.publicKey\n\n        // Initialize session manager\n        self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)\n\n        // Set up session callbacks\n        sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in\n            self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)\n        }\n\n        // Start session maintenance timer\n        startRekeyTimer()\n    }\n\n    // MARK: - BCH-01-009: Key Generation Helpers with Save Verification\n\n    /// Generate and save a new Noise static key, verifying the save succeeds\n    private static func generateAndSaveNoiseKey(keychain: KeychainManagerProtocol) -> Curve25519.KeyAgreement.PrivateKey {\n        let newKey = Curve25519.KeyAgreement.PrivateKey()\n        let keyData = newKey.rawRepresentation\n\n        // Save to keychain and verify success\n        let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: \"noiseStaticKey\")\n\n        switch saveResult {\n        case .success:\n            SecureLogger.logKeyOperation(.create, keyType: \"noiseStaticKey\", success: true)\n        case .duplicateItem:\n            // This shouldn't happen since we just tried to load, but handle it\n            SecureLogger.warning(\"Noise key already exists (race condition?)\", category: .keychain)\n        default:\n            // Save failed - log but continue with the key (it will be ephemeral)\n            SecureLogger.error(NSError(domain: \"Keychain\", code: -1),\n                               context: \"Failed to persist noise static key - identity will be lost on restart\",\n                               category: .keychain)\n        }\n\n        return newKey\n    }\n\n    /// Generate and save a new Ed25519 signing key, verifying the save succeeds\n    private static func generateAndSaveSigningKey(keychain: KeychainManagerProtocol) -> Curve25519.Signing.PrivateKey {\n        let newKey = Curve25519.Signing.PrivateKey()\n        let keyData = newKey.rawRepresentation\n\n        // Save to keychain and verify success\n        let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: \"ed25519SigningKey\")\n\n        switch saveResult {\n        case .success:\n            SecureLogger.logKeyOperation(.create, keyType: \"ed25519SigningKey\", success: true)\n        case .duplicateItem:\n            // This shouldn't happen since we just tried to load, but handle it\n            SecureLogger.warning(\"Signing key already exists (race condition?)\", category: .keychain)\n        default:\n            // Save failed - log but continue with the key (it will be ephemeral)\n            SecureLogger.error(NSError(domain: \"Keychain\", code: -1),\n                               context: \"Failed to persist signing key - identity will be lost on restart\",\n                               category: .keychain)\n        }\n\n        return newKey\n    }\n    \n    // MARK: - Public Interface\n    \n    /// Get our static public key for sharing\n    func getStaticPublicKeyData() -> Data {\n        return staticIdentityPublicKey.rawRepresentation\n    }\n    \n    /// Get our signing public key for sharing\n    func getSigningPublicKeyData() -> Data {\n        return signingPublicKey.rawRepresentation\n    }\n    \n    /// Get our identity fingerprint\n    func getIdentityFingerprint() -> String {\n        staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()\n    }\n    \n    /// Get peer's public key data\n    func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {\n        return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation\n    }\n    \n    /// Clear persistent identity (for panic mode)\n    func clearPersistentIdentity() {\n        // Clear from keychain\n        let deletedStatic = keychain.deleteIdentityKey(forKey: \"noiseStaticKey\")\n        let deletedSigning = keychain.deleteIdentityKey(forKey: \"ed25519SigningKey\")\n        SecureLogger.logKeyOperation(.delete, keyType: \"identity keys\", success: deletedStatic && deletedSigning)\n        SecureLogger.warning(\"Panic mode activated - identity cleared\", category: .security)\n        // Stop rekey timer\n        stopRekeyTimer()\n    }\n    \n    /// Sign data with our Ed25519 signing key\n    func signData(_ data: Data) -> Data? {\n        do {\n            let signature = try signingKey.signature(for: data)\n            return signature\n        } catch {\n            SecureLogger.error(error, context: \"Failed to sign data\")\n            return nil\n        }\n    }\n    \n    /// Verify signature with a peer's Ed25519 public key\n    func verifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool {\n        do {\n            let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey)\n            return signingPublicKey.isValidSignature(signature, for: data)\n        } catch {\n            SecureLogger.error(error, context: \"Failed to verify signature\")\n            return false\n        }\n    }\n\n    // MARK: - Announce Signature Helpers\n\n    /// Build the canonical announce binding message bytes and sign with our Ed25519 key\n    /// - Parameters:\n    ///   - peerID: 8-byte routing ID (as in packet header)\n    ///   - noiseKey: 32-byte Curve25519.KeyAgreement public key\n    ///   - ed25519Key: 32-byte Ed25519 public key (self)\n    ///   - nickname: UTF-8 nickname (<=255 bytes)\n    ///   - timestampMs: UInt64 milliseconds since epoch\n    /// - Returns: Ed25519 signature over the canonical bytes, or nil on failure\n    func buildAnnounceSignature(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data? {\n        let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs)\n        return signData(message)\n    }\n\n    /// Verify an announce signature\n    func verifyAnnounceSignature(signature: Data, peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64, publicKey: Data) -> Bool {\n        let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs)\n        return verifySignature(signature, for: message, publicKey: publicKey)\n    }\n\n    /// Build canonical bytes for announce signing.\n    private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {\n        var out = Data()\n        // context\n        let context = \"bitchat-announce-v1\".data(using: .utf8) ?? Data()\n        out.append(UInt8(min(context.count, 255)))\n        out.append(context.prefix(255))\n        // peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)\n        let peerID8 = peerID.prefix(8)\n        out.append(peerID8)\n        if peerID8.count < 8 { out.append(Data(repeating: 0, count: 8 - peerID8.count)) }\n        // noise static key (expect 32)\n        let noise32 = noiseKey.prefix(32)\n        out.append(noise32)\n        if noise32.count < 32 { out.append(Data(repeating: 0, count: 32 - noise32.count)) }\n        // ed25519 public key (expect 32)\n        let ed32 = ed25519Key.prefix(32)\n        out.append(ed32)\n        if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }\n        // nickname length + bytes\n        let nickData = nickname.data(using: .utf8) ?? Data()\n        out.append(UInt8(min(nickData.count, 255)))\n        out.append(nickData.prefix(255))\n        // timestamp\n        var ts = timestampMs.bigEndian\n        withUnsafeBytes(of: &ts) { raw in out.append(contentsOf: raw) }\n        return out\n    }\n    \n    // MARK: - Packet Signing/Verification\n    \n    /// Sign a BitchatPacket using the noise private key\n    func signPacket(_ packet: BitchatPacket) -> BitchatPacket? {\n        // Create canonical packet bytes for signing\n        guard let packetData = packet.toBinaryDataForSigning() else {\n            return nil\n        }\n        \n        // Sign with the noise private key (converted to Ed25519 for signing)\n        guard let signature = signData(packetData) else {\n            return nil\n        }\n        \n        // Return new packet with signature\n        var signedPacket = packet\n        signedPacket.signature = signature\n        return signedPacket\n    }\n    \n    /// Verify a BitchatPacket signature using the provided public key\n    func verifyPacketSignature(_ packet: BitchatPacket, publicKey: Data) -> Bool {\n        guard let signature = packet.signature else {\n            return false\n        }\n        \n        // Create canonical packet bytes for verification (without signature)\n        \n        guard let packetData = packet.toBinaryDataForSigning() else {\n            return false\n        }\n        \n        // For noise public keys, we need to derive the Ed25519 key for verification\n        // This assumes the noise key can be used for Ed25519 signing\n        return verifySignature(signature, for: packetData, publicKey: publicKey)\n    }\n\n    \n    // MARK: - Handshake Management\n    \n    /// Initiate a Noise handshake with a peer\n    func initiateHandshake(with peerID: PeerID) throws -> Data {\n        \n        // Validate peer ID\n        guard peerID.isValid else {\n            SecureLogger.warning(.authenticationFailed(peerID: peerID.id))\n            throw NoiseSecurityError.invalidPeerID\n        }\n        \n        // Check rate limit\n        guard rateLimiter.allowHandshake(from: peerID) else {\n            SecureLogger.warning(.authenticationFailed(peerID: \"Rate limited: \\(peerID)\"))\n            throw NoiseSecurityError.rateLimitExceeded\n        }\n        \n        SecureLogger.info(.handshakeStarted(peerID: peerID.id))\n        \n        // Return raw handshake data without wrapper\n        // The Noise protocol handles its own message format\n        let handshakeData = try sessionManager.initiateHandshake(with: peerID)\n        return handshakeData\n    }\n    \n    /// Process an incoming handshake message\n    func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {\n        \n        // Validate peer ID\n        guard peerID.isValid else {\n            SecureLogger.warning(.authenticationFailed(peerID: peerID.id))\n            throw NoiseSecurityError.invalidPeerID\n        }\n        \n        // Validate message size\n        guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {\n            SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: \"Message too large\"))\n            throw NoiseSecurityError.messageTooLarge\n        }\n        \n        // Check rate limit\n        guard rateLimiter.allowHandshake(from: peerID) else {\n            SecureLogger.warning(.authenticationFailed(peerID: \"Rate limited: \\(peerID)\"))\n            throw NoiseSecurityError.rateLimitExceeded\n        }\n        \n        // For handshakes, we process the raw data directly without NoiseMessage wrapper\n        // The Noise protocol handles its own message format\n        let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message)\n        \n        \n        // Return raw response without wrapper\n        return responsePayload\n    }\n    \n    /// Check if we have an established session with a peer\n    func hasEstablishedSession(with peerID: PeerID) -> Bool {\n        return sessionManager.getSession(for: peerID)?.isEstablished() ?? false\n    }\n    \n    /// Check if we have a session (established or handshaking) with a peer\n    func hasSession(with peerID: PeerID) -> Bool {\n        return sessionManager.getSession(for: peerID) != nil\n    }\n    \n    // MARK: - Encryption/Decryption\n    \n    /// Encrypt data for a specific peer\n    func encrypt(_ data: Data, for peerID: PeerID) throws -> Data {\n        // Validate message size\n        guard NoiseSecurityValidator.validateMessageSize(data) else {\n            throw NoiseSecurityError.messageTooLarge\n        }\n        \n        // Check rate limit\n        guard rateLimiter.allowMessage(from: peerID) else {\n            throw NoiseSecurityError.rateLimitExceeded\n        }\n        \n        // Check if we have an established session\n        guard hasEstablishedSession(with: peerID) else {\n            // Signal that handshake is needed\n            onHandshakeRequired?(peerID)\n            throw NoiseEncryptionError.handshakeRequired\n        }\n        \n        return try sessionManager.encrypt(data, for: peerID)\n    }\n    \n    /// Decrypt data from a specific peer\n    func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {\n        // Validate message size\n        guard NoiseSecurityValidator.validateMessageSize(data) else {\n            throw NoiseSecurityError.messageTooLarge\n        }\n        \n        // Check rate limit\n        guard rateLimiter.allowMessage(from: peerID) else {\n            throw NoiseSecurityError.rateLimitExceeded\n        }\n        \n        // Check if we have an established session\n        guard hasEstablishedSession(with: peerID) else {\n            throw NoiseEncryptionError.sessionNotEstablished\n        }\n        \n        return try sessionManager.decrypt(data, from: peerID)\n    }\n    \n    // MARK: - Peer Management\n    \n    /// Get fingerprint for a peer\n    func getPeerFingerprint(_ peerID: PeerID) -> String? {\n        return serviceQueue.sync {\n            return peerFingerprints[peerID]\n        }\n    }\n\n    func clearEphemeralStateForPanic() {\n        sessionManager.removeAllSessions()\n        serviceQueue.sync(flags: .barrier) {\n            peerFingerprints.removeAll()\n            fingerprintToPeerID.removeAll()\n        }\n        rateLimiter.resetAll()\n    }\n\n    /// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake)\n    func clearSession(for peerID: PeerID) {\n        sessionManager.removeSession(for: peerID)\n        serviceQueue.sync(flags: .barrier) {\n            if let fingerprint = peerFingerprints.removeValue(forKey: peerID) {\n                fingerprintToPeerID.removeValue(forKey: fingerprint)\n            }\n        }\n        SecureLogger.debug(\"🔓 Cleared Noise session for \\(peerID)\", category: .session)\n    }\n    \n    // MARK: - Private Helpers\n    \n    private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {\n        // Calculate fingerprint\n        let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()\n        \n        // Store fingerprint mapping\n        serviceQueue.sync(flags: .barrier) {\n            peerFingerprints[peerID] = fingerprint\n            fingerprintToPeerID[fingerprint] = peerID\n        }\n        \n        // Log security event\n        SecureLogger.info(.handshakeCompleted(peerID: peerID.id))\n        \n        // Notify all handlers about authentication\n        serviceQueue.async { [weak self] in\n            self?.onPeerAuthenticatedHandlers.forEach { handler in\n                handler(peerID, fingerprint)\n            }\n        }\n    }\n        \n    // MARK: - Session Maintenance\n    \n    private func startRekeyTimer() {\n        rekeyTimer = Timer.scheduledTimer(withTimeInterval: rekeyCheckInterval, repeats: true) { [weak self] _ in\n            self?.checkSessionsForRekey()\n        }\n    }\n    \n    private func stopRekeyTimer() {\n        rekeyTimer?.invalidate()\n        rekeyTimer = nil\n    }\n    \n    private func checkSessionsForRekey() {\n        let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()\n        \n        for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {\n            \n            // Attempt to rekey the session\n            do {\n                try sessionManager.initiateRekey(for: peerID)\n                SecureLogger.debug(\"Key rotation initiated for peer: \\(peerID)\", category: .security)\n                \n                // Signal that handshake is needed\n                onHandshakeRequired?(peerID)\n            } catch {\n                SecureLogger.error(error, context: \"Failed to initiate rekey for peer: \\(peerID)\", category: .session)\n            }\n        }\n    }\n    \n    deinit {\n        stopRekeyTimer()\n    }\n}\n\n// MARK: - Protocol Message Types for Noise\n\n/// Message types for the Noise encryption protocol layer.\n/// These types wrap the underlying BitChat protocol messages with encryption metadata.\nenum NoiseMessageType: UInt8 {\n    case handshakeInitiation = 0x10\n    case handshakeResponse = 0x11\n    case handshakeFinal = 0x12\n    case encryptedMessage = 0x13\n    case sessionRenegotiation = 0x14\n}\n\n// MARK: - Noise Message Wrapper\n\n/// Container for encrypted messages in the Noise protocol.\n/// Provides versioning and type information for proper message handling.\n/// The actual message content is encrypted in the payload field.\nstruct NoiseMessage: Codable {\n    let type: UInt8\n    let sessionID: String  // Random ID for this handshake session\n    let payload: Data\n    \n    init(type: NoiseMessageType, sessionID: String, payload: Data) {\n        self.type = type.rawValue\n        self.sessionID = sessionID\n        self.payload = payload\n    }\n    \n    func encode() -> Data? {\n        do {\n            let encoded = try JSONEncoder().encode(self)\n            return encoded\n        } catch {\n            return nil\n        }\n    }\n    \n    static func decode(from data: Data) -> NoiseMessage? {\n        return try? JSONDecoder().decode(NoiseMessage.self, from: data)\n    }\n    \n    static func decodeWithError(from data: Data) -> NoiseMessage? {\n        do {\n            let decoded = try JSONDecoder().decode(NoiseMessage.self, from: data)\n            return decoded\n        } catch {\n            return nil\n        }\n    }\n    \n    // MARK: - Binary Encoding\n    \n    func toBinaryData() -> Data {\n        var data = Data()\n        data.appendUInt8(type)\n        data.appendUUID(sessionID)\n        data.appendData(payload)\n        return data\n    }\n    \n    static func fromBinaryData(_ data: Data) -> NoiseMessage? {\n        // Create defensive copy\n        let dataCopy = Data(data)\n        \n        var offset = 0\n        \n        guard let type = dataCopy.readUInt8(at: &offset),\n              let sessionID = dataCopy.readUUID(at: &offset),\n              let payload = dataCopy.readData(at: &offset) else { return nil }\n        \n        guard let messageType = NoiseMessageType(rawValue: type) else { return nil }\n        \n        return NoiseMessage(type: messageType, sessionID: sessionID, payload: payload)\n    }\n}\n\n// MARK: - Errors\n\nenum NoiseEncryptionError: Error {\n    case handshakeRequired\n    case sessionNotEstablished\n}\n"
  },
  {
    "path": "bitchat/Services/NostrTransport.swift",
    "content": "import BitLogger\nimport Foundation\nimport Combine\n\n// Minimal Nostr transport conforming to Transport for offline sending\nfinal class NostrTransport: Transport, @unchecked Sendable {\n    struct Dependencies {\n        let notificationCenter: NotificationCenter\n        let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]\n        let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?\n        let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?\n        let currentIdentity: @MainActor () throws -> NostrIdentity?\n        let registerPendingGiftWrap: @MainActor (String) -> Void\n        let sendEvent: @MainActor (NostrEvent) -> Void\n        let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void\n\n        static func live(idBridge: NostrIdentityBridge) -> Dependencies {\n            Dependencies(\n                notificationCenter: .default,\n                loadFavorites: { FavoritesPersistenceService.shared.favorites },\n                favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },\n                favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },\n                currentIdentity: { try idBridge.getCurrentNostrIdentity() },\n                registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },\n                sendEvent: { NostrRelayManager.shared.sendEvent($0) },\n                scheduleAfter: { delay, action in\n                    DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)\n                }\n            )\n        }\n    }\n\n    // Provide BLE short peer ID for BitChat embedding\n    var senderPeerID = PeerID(str: \"\")\n\n    // Throttle READ receipts to avoid relay rate limits\n    private struct QueuedRead {\n        let receipt: ReadReceipt\n        let peerID: PeerID\n    }\n    private var readQueue: [QueuedRead] = []\n    private var isSendingReadAcks = false\n    private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval\n    private let keychain: KeychainManagerProtocol\n    private let idBridge: NostrIdentityBridge\n    private let dependencies: Dependencies\n    private var favoriteStatusObserver: NSObjectProtocol?\n\n    // Reachability Cache (thread-safe)\n    private var reachablePeers: Set<PeerID> = []\n    private let queue = DispatchQueue(label: \"nostr.transport.state\", attributes: .concurrent)\n\n    @MainActor\n    init(\n        keychain: KeychainManagerProtocol,\n        idBridge: NostrIdentityBridge,\n        dependencies: Dependencies? = nil\n    ) {\n        self.keychain = keychain\n        self.idBridge = idBridge\n        self.dependencies = dependencies ?? .live(idBridge: idBridge)\n        \n        setupObservers()\n        \n        // Synchronously warm the cache to avoid startup race\n        let favorites = self.dependencies.loadFavorites()\n        let reachable = favorites.values\n            .filter { $0.peerNostrPublicKey != nil }\n            .map { PeerID(publicKey: $0.peerNoisePublicKey) }\n            \n        queue.sync(flags: .barrier) {\n            self.reachablePeers = Set(reachable)\n        }\n    }\n\n    deinit {\n        if let favoriteStatusObserver {\n            dependencies.notificationCenter.removeObserver(favoriteStatusObserver)\n        }\n    }\n\n    private func setupObservers() {\n        favoriteStatusObserver = dependencies.notificationCenter.addObserver(\n            forName: .favoriteStatusChanged,\n            object: nil,\n            queue: nil\n        ) { [weak self] _ in\n            self?.refreshReachablePeers()\n        }\n    }\n\n    private func refreshReachablePeers() {\n        Task { @MainActor in\n            let favorites = dependencies.loadFavorites()\n            let reachable = favorites.values\n                .filter { $0.peerNostrPublicKey != nil }\n                .map { PeerID(publicKey: $0.peerNoisePublicKey) }\n            \n            self.queue.async(flags: .barrier) { [weak self] in\n                self?.reachablePeers = Set(reachable)\n            }\n        }\n    }\n\n    // MARK: - Transport Protocol Conformance\n\n    weak var delegate: BitchatDelegate?\n    weak var peerEventsDelegate: TransportPeerEventsDelegate?\n\n    var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {\n        Just([]).eraseToAnyPublisher()\n    }\n    func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }\n\n    var myPeerID: PeerID { senderPeerID }\n    var myNickname: String { \"\" }\n    func setNickname(_ nickname: String) { /* not used for Nostr */ }\n\n    func startServices() { /* no-op */ }\n    func stopServices() { /* no-op */ }\n    func emergencyDisconnectAll() { /* no-op */ }\n\n    func isPeerConnected(_ peerID: PeerID) -> Bool { false }\n    \n    func isPeerReachable(_ peerID: PeerID) -> Bool {\n        queue.sync {\n            // Check if exact match\n            if reachablePeers.contains(peerID) { return true }\n            // Check for short ID match\n            if peerID.isShort {\n                return reachablePeers.contains(where: { $0.toShort() == peerID })\n            }\n            return false\n        }\n    }\n    \n    func peerNickname(peerID: PeerID) -> String? { nil }\n    func getPeerNicknames() -> [PeerID : String] { [:] }\n\n    func getFingerprint(for peerID: PeerID) -> String? { nil }\n    func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }\n    func triggerHandshake(with peerID: PeerID) { /* no-op */ }\n    \n    // Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation\n    private static var cachedNoiseService: NoiseEncryptionService?\n    func getNoiseService() -> NoiseEncryptionService {\n        if let noiseService = Self.cachedNoiseService {\n            return noiseService\n        }\n        let noiseService = NoiseEncryptionService(keychain: keychain)\n        Self.cachedNoiseService = noiseService\n        return noiseService\n    }\n\n    // Public broadcast not supported over Nostr here\n    func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }\n\n    func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {\n        Task { @MainActor in\n            guard let recipientNpub = resolveRecipientNpub(for: peerID),\n                  let recipientHex = npubToHex(recipientNpub),\n                  let senderIdentity = try? dependencies.currentIdentity() else { return }\n            SecureLogger.debug(\"NostrTransport: preparing PM to \\(recipientNpub.prefix(16))… id=\\(messageID.prefix(8))…\", category: .session)\n            guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {\n                SecureLogger.error(\"NostrTransport: failed to embed PM packet\", category: .session)\n                return\n            }\n            sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)\n        }\n    }\n\n    func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {\n        // Enqueue and process with throttling to avoid relay rate limits\n        // Use barrier to synchronize access to readQueue\n        queue.async(flags: .barrier) { [weak self] in\n            self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))\n            self?.processReadQueueIfNeeded()\n        }\n    }\n\n    func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {\n        Task { @MainActor in\n            guard let recipientNpub = resolveRecipientNpub(for: peerID),\n                  let recipientHex = npubToHex(recipientNpub),\n                  let senderIdentity = try? dependencies.currentIdentity() else { return }\n            let content = isFavorite ? \"[FAVORITED]:\\(senderIdentity.npub)\" : \"[UNFAVORITED]:\\(senderIdentity.npub)\"\n            SecureLogger.debug(\"NostrTransport: preparing FAVORITE(\\(isFavorite)) to \\(recipientNpub.prefix(16))…\", category: .session)\n            guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {\n                SecureLogger.error(\"NostrTransport: failed to embed favorite notification\", category: .session)\n                return\n            }\n            sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)\n        }\n    }\n\n    func sendBroadcastAnnounce() { /* no-op for Nostr */ }\n    func sendDeliveryAck(for messageID: String, to peerID: PeerID) {\n        Task { @MainActor in\n            guard let recipientNpub = resolveRecipientNpub(for: peerID),\n                  let recipientHex = npubToHex(recipientNpub),\n                  let senderIdentity = try? dependencies.currentIdentity() else { return }\n            SecureLogger.debug(\"NostrTransport: preparing DELIVERED ack id=\\(messageID.prefix(8))…\", category: .session)\n            guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {\n                SecureLogger.error(\"NostrTransport: failed to embed DELIVERED ack\", category: .session)\n                return\n            }\n            sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)\n        }\n    }\n}\n\n// MARK: - Geohash Helpers\n\nextension NostrTransport {\n\n    // MARK: Geohash ACK helpers\n    func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {\n        Task { @MainActor in\n            SecureLogger.debug(\"GeoDM: send DELIVERED mid=\\(messageID.prefix(8))…\", category: .session)\n            guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }\n            sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)\n        }\n    }\n\n    func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {\n        Task { @MainActor in\n            SecureLogger.debug(\"GeoDM: send READ mid=\\(messageID.prefix(8))…\", category: .session)\n            guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }\n            sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)\n        }\n    }\n\n    // MARK: Geohash DMs (per-geohash identity)\n    func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {\n        Task { @MainActor in\n            guard !recipientHex.isEmpty else { return }\n            SecureLogger.debug(\"GeoDM: send PM mid=\\(messageID.prefix(8))…\", category: .session)\n            guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {\n                SecureLogger.error(\"NostrTransport: failed to embed geohash PM packet\", category: .session)\n                return\n            }\n            sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)\n        }\n    }\n}\n\n// MARK: - Private Helpers\n\nextension NostrTransport {\n    /// Converts npub bech32 string to hex pubkey\n    @MainActor\n    private func npubToHex(_ npub: String) -> String? {\n        do {\n            let (hrp, data) = try Bech32.decode(npub)\n            guard hrp == \"npub\" else { return nil }\n            return data.hexEncodedString()\n        } catch {\n            SecureLogger.error(\"NostrTransport: failed to decode npub -> hex: \\(error)\", category: .session)\n            return nil\n        }\n    }\n\n    /// Creates and sends a gift-wrapped private message event\n    @MainActor\n    private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {\n        guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {\n            SecureLogger.error(\"NostrTransport: failed to build Nostr event\", category: .session)\n            return\n        }\n        if registerPending {\n            dependencies.registerPendingGiftWrap(event.id)\n        }\n        dependencies.sendEvent(event)\n    }\n\n    /// Must be called within a barrier on `queue`\n    private func processReadQueueIfNeeded() {\n        guard !isSendingReadAcks else { return }\n        guard !readQueue.isEmpty else { return }\n        isSendingReadAcks = true\n        let item = readQueue.removeFirst()\n        sendReadAckItem(item)\n    }\n\n    /// Sends a single read ack item (called after extraction from queue within barrier)\n    private func sendReadAckItem(_ item: QueuedRead) {\n        Task { @MainActor in\n            defer { scheduleNextReadAck() }\n            guard let recipientNpub = resolveRecipientNpub(for: item.peerID),\n                  let recipientHex = npubToHex(recipientNpub),\n                  let senderIdentity = try? dependencies.currentIdentity() else { return }\n            SecureLogger.debug(\"NostrTransport: preparing READ ack id=\\(item.receipt.originalMessageID.prefix(8))…\", category: .session)\n            guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {\n                SecureLogger.error(\"NostrTransport: failed to embed READ ack\", category: .session)\n                return\n            }\n            sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)\n        }\n    }\n\n    private func scheduleNextReadAck() {\n        dependencies.scheduleAfter(readAckInterval) { [weak self] in\n            self?.queue.async(flags: .barrier) { [weak self] in\n                self?.isSendingReadAcks = false\n                self?.processReadQueueIfNeeded()\n            }\n        }\n    }\n\n    @MainActor\n    private func resolveRecipientNpub(for peerID: PeerID) -> String? {\n        if let noiseKey = Data(hexString: peerID.id),\n           let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),\n           let npub = fav.peerNostrPublicKey {\n            return npub\n        }\n        if peerID.id.count == 16,\n           let fav = dependencies.favoriteStatusForPeerID(peerID),\n           let npub = fav.peerNostrPublicKey {\n            return npub\n        }\n        return nil\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/NotificationService.swift",
    "content": "//\n// NotificationService.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\nimport UserNotifications\n#if os(iOS)\nimport UIKit\n#elseif os(macOS)\nimport AppKit\n#endif\n\nprotocol NotificationAuthorizing {\n    func requestAuthorization(\n        options: UNAuthorizationOptions,\n        completionHandler: @escaping (Bool, Error?) -> Void\n    )\n}\n\nprotocol NotificationRequestDelivering {\n    func add(_ request: UNNotificationRequest)\n}\n\nprivate final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {\n    private let center: UNUserNotificationCenter\n\n    init(center: UNUserNotificationCenter) {\n        self.center = center\n    }\n\n    func requestAuthorization(\n        options: UNAuthorizationOptions,\n        completionHandler: @escaping (Bool, Error?) -> Void\n    ) {\n        center.requestAuthorization(options: options, completionHandler: completionHandler)\n    }\n}\n\nprivate final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering {\n    private let center: UNUserNotificationCenter\n\n    init(center: UNUserNotificationCenter) {\n        self.center = center\n    }\n\n    func add(_ request: UNNotificationRequest) {\n        Task {\n            try? await center.add(request)\n        }\n    }\n}\n\nprivate struct NoopNotificationAuthorizer: NotificationAuthorizing {\n    func requestAuthorization(\n        options: UNAuthorizationOptions,\n        completionHandler: @escaping (Bool, Error?) -> Void\n    ) {\n        completionHandler(false, nil)\n    }\n}\n\nprivate struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {\n    func add(_ request: UNNotificationRequest) {}\n}\n\nfinal class NotificationService {\n    static let shared = NotificationService()\n\n    private let isRunningTestsProvider: () -> Bool\n    private let authorizer: NotificationAuthorizing\n    private let requestDeliverer: NotificationRequestDelivering\n\n    /// Returns true if running in test environment (XCTest, Swift Testing, or CI)\n    private var isRunningTests: Bool {\n        isRunningTestsProvider()\n    }\n\n    private init() {\n        self.isRunningTestsProvider = {\n            let env = ProcessInfo.processInfo.environment\n            return NSClassFromString(\"XCTestCase\") != nil ||\n                   env[\"XCTestConfigurationFilePath\"] != nil ||\n                   env[\"XCTestBundlePath\"] != nil ||\n                   env[\"GITHUB_ACTIONS\"] != nil ||\n                   env[\"CI\"] != nil\n        }\n        if isRunningTestsProvider() {\n            self.authorizer = NoopNotificationAuthorizer()\n            self.requestDeliverer = NoopNotificationRequestDeliverer()\n        } else {\n            let center = UNUserNotificationCenter.current()\n            self.authorizer = NotificationCenterAuthorizerAdapter(center: center)\n            self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)\n        }\n    }\n\n    internal init(\n        isRunningTestsProvider: @escaping () -> Bool,\n        authorizer: NotificationAuthorizing,\n        requestDeliverer: NotificationRequestDelivering\n    ) {\n        self.isRunningTestsProvider = isRunningTestsProvider\n        self.authorizer = authorizer\n        self.requestDeliverer = requestDeliverer\n    }\n\n    func requestAuthorization() {\n        guard !isRunningTests else { return }\n        authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in\n            if granted {\n                // Permission granted\n            } else {\n                // Permission denied\n            }\n        }\n    }\n    \n    func sendLocalNotification(\n        title: String,\n        body: String,\n        identifier: String,\n        userInfo: [String: Any]? = nil,\n        interruptionLevel: UNNotificationInterruptionLevel = .active\n    ) {\n        guard !isRunningTests else { return }\n        let content = UNMutableNotificationContent()\n        content.title = title\n        content.body = body\n        content.sound = .default\n        content.interruptionLevel = interruptionLevel\n\n        if let userInfo = userInfo {\n            content.userInfo = userInfo\n        }\n\n        let request = UNNotificationRequest(\n            identifier: identifier,\n            content: content,\n            trigger: nil // Deliver immediately\n        )\n\n        requestDeliverer.add(request)\n    }\n    \n    func sendMentionNotification(from sender: String, message: String) {\n        let title = \"🫵 you were mentioned by \\(sender)\"\n        let body = message\n        let identifier = \"mention-\\(UUID().uuidString)\"\n        \n        sendLocalNotification(title: title, body: body, identifier: identifier)\n    }\n    \n    func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {\n        let title = \"🔒 DM from \\(sender)\"\n        let body = message\n        let identifier = \"private-\\(UUID().uuidString)\"\n        let userInfo = [\"peerID\": peerID.id, \"senderName\": sender]\n        \n        sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)\n    }\n    \n    // Geohash public chat notification with deep link to a specific geohash\n    func sendGeohashActivityNotification(geohash: String, titlePrefix: String = \"#\", bodyPreview: String) {\n        let title = \"\\(titlePrefix)\\(geohash)\"\n        let identifier = \"geo-activity-\\(geohash)-\\(Date().timeIntervalSince1970)\"\n        let deeplink = \"bitchat://geohash/\\(geohash)\"\n        let userInfo: [String: Any] = [\"deeplink\": deeplink]\n        sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo)\n    }\n\n    func sendNetworkAvailableNotification(peerCount: Int) {\n        let title = \"👥 bitchatters nearby!\"\n        let body = peerCount == 1 ? \"1 person around\" : \"\\(peerCount) people around\"\n        // Fixed identifier so iOS updates the existing notification instead of creating new ones\n        let identifier = \"network-available\"\n\n        sendLocalNotification(\n            title: title,\n            body: body,\n            identifier: identifier,\n            interruptionLevel: .timeSensitive\n        )\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/NotificationStreamAssembler.swift",
    "content": "//\n// NotificationStreamAssembler.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport BitLogger\nimport Foundation\n\nstruct NotificationStreamAssembler {\n    private var buffer = Data()\n    private var pendingFrameStartedAt: DispatchTime?\n    private var pendingFrameExpectedLength: Int = 0\n\n    private mutating func resetState() {\n        buffer.removeAll(keepingCapacity: false)\n        pendingFrameStartedAt = nil\n        pendingFrameExpectedLength = 0\n    }\n\n    mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {\n        guard !chunk.isEmpty else { return ([], [], false) }\n\n        buffer.append(chunk)\n\n        var frames: [Data] = []\n        var dropped: [UInt8] = []\n        var didReset = false\n        let now = DispatchTime.now()\n        let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes\n        let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize\n\n        if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {\n            SecureLogger.error(\"❌ Notification assembler overflow (\\(buffer.count) bytes); dropping partial frame\", category: .session)\n            resetState()\n            return ([], [], true)\n        }\n\n        while buffer.count >= minimumFramePrefix {\n            guard let version = buffer.first else { break }\n            guard version == 1 || version == 2 else {\n                dropped.append(buffer.removeFirst())\n                pendingFrameStartedAt = nil\n                pendingFrameExpectedLength = 0\n                continue\n            }\n\n            guard let headerSize = BinaryProtocol.headerSize(for: version) else {\n                dropped.append(buffer.removeFirst())\n                pendingFrameStartedAt = nil\n                pendingFrameExpectedLength = 0\n                continue\n            }\n            let framePrefix = headerSize + BinaryProtocol.senderIDSize\n            guard buffer.count >= framePrefix else { break }\n\n            let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags\n            guard flagsIndex < buffer.endIndex else { break }\n            let flags = buffer[flagsIndex]\n            let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0\n            let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0\n            let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0\n            let hasRoute = (version >= 2) && (flags & BinaryProtocol.Flags.hasRoute) != 0\n\n            let lengthOffset = 12\n            let payloadLength: Int\n            if version == 2 {\n                let lengthIndex = buffer.startIndex + lengthOffset\n                payloadLength =\n                    (Int(buffer[lengthIndex]) << 24) |\n                    (Int(buffer[lengthIndex + 1]) << 16) |\n                    (Int(buffer[lengthIndex + 2]) << 8) |\n                    Int(buffer[lengthIndex + 3])\n            } else {\n                let lengthIndex = buffer.startIndex + lengthOffset\n                payloadLength = (Int(buffer[lengthIndex]) << 8) | Int(buffer[lengthIndex + 1])\n            }\n\n            var frameLength = framePrefix + payloadLength\n            if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }\n            if hasSignature { frameLength += BinaryProtocol.signatureSize }\n            \n            if hasRoute {\n                let routeCountOffset = framePrefix + (hasRecipient ? BinaryProtocol.recipientIDSize : 0)\n                let routeCountIndex = buffer.startIndex + routeCountOffset\n                guard buffer.count > routeCountOffset else { break }\n                let routeCount = Int(buffer[routeCountIndex])\n                frameLength += 1 + (routeCount * BinaryProtocol.senderIDSize)\n            }\n            \n            if isCompressed {\n                let rawLengthFieldBytes = (version == 2) ? 4 : 2\n                if payloadLength < rawLengthFieldBytes {\n                    SecureLogger.error(\"❌ Invalid compressed payload length (\\(payloadLength))\", category: .session)\n                    resetState()\n                    didReset = true\n                    break\n                }\n            }\n\n            guard frameLength > 0, frameLength <= maxFrameLength else {\n                SecureLogger.error(\"❌ Notification frame length \\(frameLength) invalid (cap=\\(maxFrameLength)); resetting stream\", category: .session)\n                resetState()\n                didReset = true\n                break\n            }\n\n            if buffer.count < frameLength {\n                let remaining = frameLength - buffer.count\n                if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {\n                    pendingFrameStartedAt = now\n                    pendingFrameExpectedLength = frameLength\n                } else if let started = pendingFrameStartedAt {\n                    let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds\n                    let threshold = UInt64(TransportConfig.bleAssemblerStallResetMs) * 1_000_000\n                    if elapsed >= threshold {\n                        SecureLogger.debug(\"📉 Resetting notification assembler after waiting \\(remaining)B for \\(TransportConfig.bleAssemblerStallResetMs)ms\", category: .session)\n                        resetState()\n                        didReset = true\n                    } else {\n                        SecureLogger.debug(\"⌛ Waiting for remaining \\(remaining)B to complete BLE frame\", category: .session)\n                    }\n                }\n                break\n            }\n\n            pendingFrameStartedAt = nil\n            pendingFrameExpectedLength = 0\n\n            let frame = Data(buffer.prefix(frameLength))\n            frames.append(frame)\n            buffer.removeFirst(frameLength)\n        }\n\n        if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {\n            resetState()\n        }\n\n        return (frames, dropped, didReset)\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/PrivateChatManager.swift",
    "content": "//\n// PrivateChatManager.swift\n// bitchat\n//\n// Manages private chat sessions and messages\n// This is free and unencumbered software released into the public domain.\n//\n\nimport BitLogger\nimport Foundation\nimport SwiftUI\n\n/// Manages all private chat functionality\nfinal class PrivateChatManager: ObservableObject {\n    @Published var privateChats: [PeerID: [BitchatMessage]] = [:]\n    @Published var selectedPeer: PeerID? = nil\n    @Published var unreadMessages: Set<PeerID> = []\n\n    private var selectedPeerFingerprint: String? = nil\n    var sentReadReceipts: Set<String> = []  // Made accessible for ChatViewModel\n\n    weak var meshService: Transport?\n    // Route acks/receipts via MessageRouter (chooses mesh or Nostr)\n    weak var messageRouter: MessageRouter?\n    // Peer service for looking up peer info during consolidation\n    weak var unifiedPeerService: UnifiedPeerService?\n\n    init(meshService: Transport? = nil) {\n        self.meshService = meshService\n    }\n\n    // Cap for messages stored per private chat\n    private let privateChatCap = TransportConfig.privateChatCap\n\n    // MARK: - Message Consolidation\n\n    /// Consolidates messages from different peer ID representations into a single chat.\n    /// This ensures messages from stable Noise keys and temporary Nostr peer IDs are merged.\n    /// - Parameters:\n    ///   - peerID: The target peer ID to consolidate messages into\n    ///   - peerNickname: The peer's display name (lowercased for matching)\n    ///   - persistedReadReceipts: The persisted read receipts set from ChatViewModel (UserDefaults-backed)\n    /// - Returns: True if any unread messages were found during consolidation\n    @MainActor\n    func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {\n        guard let meshService = meshService else { return false }\n        var hasUnreadMessages = false\n\n        // 1. Consolidate from stable Noise key (64-char hex)\n        if let peer = unifiedPeerService?.getPeer(by: peerID) {\n            let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)\n\n            if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {\n                if privateChats[peerID] == nil {\n                    privateChats[peerID] = []\n                }\n\n                let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])\n                for message in nostrMessages {\n                    if !existingMessageIds.contains(message.id) {\n                        // Update senderPeerID for correct read receipts\n                        let updatedMessage = BitchatMessage(\n                            id: message.id,\n                            sender: message.sender,\n                            content: message.content,\n                            timestamp: message.timestamp,\n                            isRelay: message.isRelay,\n                            originalSender: message.originalSender,\n                            isPrivate: message.isPrivate,\n                            recipientNickname: message.recipientNickname,\n                            senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,\n                            mentions: message.mentions,\n                            deliveryStatus: message.deliveryStatus\n                        )\n                        privateChats[peerID]?.append(updatedMessage)\n\n                        // Check for recent unread messages (< 60s, not sent by us, not already read)\n                        // Use persistedReadReceipts to correctly identify already-read messages after app restart\n                        if message.senderPeerID != meshService.myPeerID {\n                            let messageAge = Date().timeIntervalSince(message.timestamp)\n                            if messageAge < 60 && !persistedReadReceipts.contains(message.id) {\n                                hasUnreadMessages = true\n                            }\n                        }\n                    }\n                }\n\n                privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }\n\n                if hasUnreadMessages {\n                    unreadMessages.insert(peerID)\n                } else if unreadMessages.contains(noiseKeyHex) {\n                    unreadMessages.remove(noiseKeyHex)\n                }\n\n                privateChats.removeValue(forKey: noiseKeyHex)\n            }\n        }\n\n        // 2. Consolidate from temporary Nostr peer IDs (nostr_* prefixed)\n        let normalizedNickname = peerNickname.lowercased()\n        var tempPeerIDsToConsolidate: [PeerID] = []\n\n        for (storedPeerID, messages) in privateChats {\n            if storedPeerID.isGeoDM && storedPeerID != peerID {\n                let nicknamesMatch = messages.allSatisfy { $0.sender.lowercased() == normalizedNickname }\n                if nicknamesMatch && !messages.isEmpty {\n                    tempPeerIDsToConsolidate.append(storedPeerID)\n                }\n            }\n        }\n\n        if !tempPeerIDsToConsolidate.isEmpty {\n            if privateChats[peerID] == nil {\n                privateChats[peerID] = []\n            }\n\n            let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])\n            var consolidatedCount = 0\n            var hadUnreadTemp = false\n\n            for tempPeerID in tempPeerIDsToConsolidate {\n                if unreadMessages.contains(tempPeerID) {\n                    hadUnreadTemp = true\n                }\n\n                if let tempMessages = privateChats[tempPeerID] {\n                    for message in tempMessages {\n                        if !existingMessageIds.contains(message.id) {\n                            let updatedMessage = BitchatMessage(\n                                id: message.id,\n                                sender: message.sender,\n                                content: message.content,\n                                timestamp: message.timestamp,\n                                isRelay: message.isRelay,\n                                originalSender: message.originalSender,\n                                isPrivate: message.isPrivate,\n                                recipientNickname: message.recipientNickname,\n                                senderPeerID: peerID,\n                                mentions: message.mentions,\n                                deliveryStatus: message.deliveryStatus\n                            )\n                            privateChats[peerID]?.append(updatedMessage)\n                            consolidatedCount += 1\n                        }\n                    }\n                    privateChats.removeValue(forKey: tempPeerID)\n                    unreadMessages.remove(tempPeerID)\n                }\n            }\n\n            if hadUnreadTemp {\n                unreadMessages.insert(peerID)\n                hasUnreadMessages = true\n                SecureLogger.debug(\"📬 Transferred unread status from temp peer IDs to \\(peerID)\", category: .session)\n            }\n\n            if consolidatedCount > 0 {\n                privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }\n                SecureLogger.info(\"📥 Consolidated \\(consolidatedCount) Nostr messages from temporary peer IDs to \\(peerNickname)\", category: .session)\n            }\n        }\n\n        return hasUnreadMessages\n    }\n\n    /// Syncs the read receipt tracking between manager and view model for sent messages\n    @MainActor\n    func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {\n        guard let messages = privateChats[peerID] else { return }\n\n        for message in messages {\n            if message.sender == nickname {\n                if let status = message.deliveryStatus {\n                    switch status {\n                    case .read, .delivered:\n                        externalReceipts.insert(message.id)\n                        sentReadReceipts.insert(message.id)\n                    case .failed, .partiallyDelivered, .sending, .sent:\n                        break\n                    }\n                }\n            }\n        }\n    }\n    \n    /// Start a private chat with a peer\n    func startChat(with peerID: PeerID) {\n        selectedPeer = peerID\n        \n        // Store fingerprint for persistence across reconnections\n        if let fingerprint = meshService?.getFingerprint(for: peerID) {\n            selectedPeerFingerprint = fingerprint\n        }\n        \n        // Mark messages as read\n        markAsRead(from: peerID)\n        \n        // Initialize chat if needed\n        if privateChats[peerID] == nil {\n            privateChats[peerID] = []\n        }\n    }\n    \n    /// End the current private chat\n    func endChat() {\n        selectedPeer = nil\n        selectedPeerFingerprint = nil\n    }\n\n    /// Remove duplicate messages by ID and keep chronological order\n    func sanitizeChat(for peerID: PeerID) {\n        guard let arr = privateChats[peerID] else { return }\n        if arr.count <= 1 {\n            return\n        }\n\n        var indexByID: [String: Int] = [:]\n        indexByID.reserveCapacity(arr.count)\n        var deduped: [BitchatMessage] = []\n        deduped.reserveCapacity(arr.count)\n\n        for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {\n            if let existing = indexByID[msg.id] {\n                deduped[existing] = msg\n            } else {\n                indexByID[msg.id] = deduped.count\n                deduped.append(msg)\n            }\n        }\n\n        privateChats[peerID] = deduped\n    }\n    \n    /// Mark messages from a peer as read\n    func markAsRead(from peerID: PeerID) {\n        unreadMessages.remove(peerID)\n        \n        // Send read receipts for unread messages that haven't been sent yet\n        if let messages = privateChats[peerID] {\n            for message in messages {\n                if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {\n                    sendReadReceipt(for: message)\n                }\n            }\n        }\n    }\n    \n    // MARK: - Private Methods\n    \n    private func sendReadReceipt(for message: BitchatMessage) {\n        guard !sentReadReceipts.contains(message.id),\n              let senderPeerID = message.senderPeerID else {\n            return\n        }\n        \n        sentReadReceipts.insert(message.id)\n        \n        // Create read receipt using the simplified method\n        let receipt = ReadReceipt(\n            originalMessageID: message.id,\n            readerID: meshService?.myPeerID ?? PeerID(str: \"\"),\n            readerNickname: meshService?.myNickname ?? \"\"\n        )\n        \n        // Route via MessageRouter to avoid handshakeRequired spam when session isn't established\n        if let router = messageRouter {\n            SecureLogger.debug(\"PrivateChatManager: sending READ ack for \\(message.id.prefix(8))… to \\(senderPeerID.id.prefix(8))… via router\", category: .session)\n            Task { @MainActor in\n                router.sendReadReceipt(receipt, to: senderPeerID)\n            }\n        } else {\n            // Fallback: preserve previous behavior\n            meshService?.sendReadReceipt(receipt, to: senderPeerID)\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/RelayController.swift",
    "content": "import Foundation\n\n// RelayDecision encapsulates a single relay scheduling choice.\nstruct RelayDecision {\n    let shouldRelay: Bool\n    let newTTL: UInt8\n    let delayMs: Int\n}\n\n// RelayController centralizes flood control policy for relays.\nstruct RelayController {\n    static func decide(ttl: UInt8,\n                       senderIsSelf: Bool,\n                       isEncrypted: Bool,\n                       isDirectedEncrypted: Bool,\n                       isFragment: Bool,\n                       isDirectedFragment: Bool,\n                       isHandshake: Bool,\n                       isAnnounce: Bool,\n                       degree: Int,\n                       highDegreeThreshold: Int) -> RelayDecision {\n        let ttlCap = min(ttl, TransportConfig.messageTTLDefault)\n\n        // Suppress obvious non-relays\n        if ttlCap <= 1 || senderIsSelf {\n            return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)\n        }\n\n        // For session-critical or directed traffic, be deterministic and reliable\n        if isHandshake || isDirectedFragment || isDirectedEncrypted {\n            // Always relay with no TTL cap for these types\n            let newTTL = ttlCap &- 1\n            // Slight jitter to desynchronize without adding too much latency\n            // Tighter for faster multi-hop handshakes and directed DMs\n            let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60\n            let delayMs = Int.random(in: delayRange)\n            return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)\n        }\n\n        if isFragment {\n            let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)\n            guard ttlLimit > 1 else {\n                return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)\n            }\n            let newTTL = ttlLimit &- 1\n            let delayMs = Int.random(in: TransportConfig.bleFragmentRelayMinDelayMs...TransportConfig.bleFragmentRelayMaxDelayMs)\n            return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)\n        }\n\n        // TTL clamping for broadcast\n        // - Dense graphs: keep lower but still allow multi-hop bridging\n        // - Announces get a bit more headroom\n        let ttlLimit: UInt8 = {\n            if degree >= highDegreeThreshold {\n                return max(UInt8(2), min(ttlCap, UInt8(5)))\n            }\n            let preferred = UInt8(isAnnounce ? 7 : 6)\n            return max(UInt8(2), min(ttlCap, preferred))\n        }()\n        let newTTL = ttlLimit &- 1\n\n        // Wider jitter window to allow duplicate suppression to win more often\n        // For sparse graphs (<=2), relay quickly to avoid cancellation races\n        let delayMs: Int\n        switch degree {\n        case 0...2: delayMs = Int.random(in: 10...40)\n        case 3...5: delayMs = Int.random(in: 60...150)\n        case 6...9: delayMs = Int.random(in: 80...180)\n        default:    delayMs = Int.random(in: 100...220)\n        }\n        return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/TransferProgressManager.swift",
    "content": "import Foundation\nimport Combine\n\n/// Centralized progress bus for Bluetooth file transfers.\n/// Emits Combine events consumed by ChatViewModel to update UI progress indicators.\nfinal class TransferProgressManager {\n    static let shared = TransferProgressManager()\n\n    enum Event {\n        case started(id: String, totalFragments: Int)\n        case updated(id: String, sentFragments: Int, totalFragments: Int)\n        case completed(id: String, totalFragments: Int)\n        case cancelled(id: String, sentFragments: Int, totalFragments: Int)\n    }\n\n    private let subject = PassthroughSubject<Event, Never>()\n    private let queue = DispatchQueue(label: \"com.bitchat.transfer-progress\", attributes: .concurrent)\n    private var states: [String: (sent: Int, total: Int)] = [:]\n\n    var publisher: AnyPublisher<Event, Never> {\n        subject.eraseToAnyPublisher()\n    }\n\n    func start(id: String, totalFragments: Int) {\n        queue.async(flags: .barrier) { [weak self] in\n            guard let self = self else { return }\n            self.states[id] = (sent: 0, total: totalFragments)\n            self.subject.send(.started(id: id, totalFragments: totalFragments))\n        }\n    }\n\n    func recordFragmentSent(id: String) {\n        queue.async(flags: .barrier) { [weak self] in\n            guard let self = self, var state = self.states[id] else { return }\n            state.sent = min(state.sent + 1, state.total)\n            self.states[id] = state\n            self.subject.send(.updated(id: id, sentFragments: state.sent, totalFragments: state.total))\n            if state.sent >= state.total {\n                self.states.removeValue(forKey: id)\n                self.subject.send(.completed(id: id, totalFragments: state.total))\n            }\n        }\n    }\n\n    func cancel(id: String) {\n        queue.async(flags: .barrier) { [weak self] in\n            guard let self = self, let state = self.states.removeValue(forKey: id) else { return }\n            self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total))\n        }\n    }\n\n    func reset(id: String) {\n        queue.async(flags: .barrier) { [weak self] in\n            self?.states.removeValue(forKey: id)\n        }\n    }\n\n    func snapshot(id: String) -> (sent: Int, total: Int)? {\n        var result: (sent: Int, total: Int)?\n        queue.sync {\n            result = states[id]\n        }\n        return result\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/Transport.swift",
    "content": "import Foundation\nimport Combine\n\n/// Abstract transport interface used by ChatViewModel and services.\n/// BLEService implements this protocol; a future Nostr transport can too.\nstruct TransportPeerSnapshot: Equatable, Hashable {\n    let peerID: PeerID\n    let nickname: String\n    let isConnected: Bool\n    let noisePublicKey: Data?\n    let lastSeen: Date\n}\n\nprotocol Transport: AnyObject {\n    // Event sink\n    var delegate: BitchatDelegate? { get set }\n    // Peer events (preferred over publishers for UI)\n    var peerEventsDelegate: TransportPeerEventsDelegate? { get set }\n    \n    // Peer snapshots (for non-UI services)\n    var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }\n    func currentPeerSnapshots() -> [TransportPeerSnapshot]\n\n    // Identity\n    var myPeerID: PeerID { get }\n    var myNickname: String { get }\n    func setNickname(_ nickname: String)\n\n    // Lifecycle\n    func startServices()\n    func stopServices()\n    func emergencyDisconnectAll()\n\n    // Connectivity and peers\n    func isPeerConnected(_ peerID: PeerID) -> Bool\n    func isPeerReachable(_ peerID: PeerID) -> Bool\n    func peerNickname(peerID: PeerID) -> String?\n    func getPeerNicknames() -> [PeerID: String]\n\n    // Protocol utilities\n    func getFingerprint(for peerID: PeerID) -> String?\n    func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState\n    func triggerHandshake(with peerID: PeerID)\n    func getNoiseService() -> NoiseEncryptionService\n\n    // Messaging\n    func sendMessage(_ content: String, mentions: [String])\n    func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)\n    func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)\n    func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)\n    func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)\n    func sendBroadcastAnnounce()\n    func sendDeliveryAck(for messageID: String, to peerID: PeerID)\n    func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)\n    func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)\n    func cancelTransfer(_ transferId: String)\n\n    // QR verification (optional for transports)\n    func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)\n    func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)\n\n    // Pending file management (BCH-01-002: files held in memory until user accepts)\n    func acceptPendingFile(id: String) -> URL?\n    func declinePendingFile(id: String)\n}\n\nextension Transport {\n    func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}\n    func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}\n    func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}\n    func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}\n    func cancelTransfer(_ transferId: String) {}\n\n    func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {\n        sendMessage(content, mentions: mentions)\n    }\n\n    func acceptPendingFile(id: String) -> URL? { nil }\n    func declinePendingFile(id: String) {}\n}\n\nprotocol TransportPeerEventsDelegate: AnyObject {\n    @MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])\n}\n\nextension BLEService: Transport {}\n"
  },
  {
    "path": "bitchat/Services/TransportConfig.swift",
    "content": "import Foundation\n\n/// Centralized knobs for transport- and UI-related limits.\n/// Keep values aligned with existing behavior when replacing magic numbers.\nenum TransportConfig {\n    // BLE / Protocol\n    static let bleDefaultFragmentSize: Int = 469            // ~512 MTU minus protocol overhead\n    static let messageTTLDefault: UInt8 = 7                 // Default TTL for mesh flooding\n    static let bleMaxInFlightAssemblies: Int = 128          // Cap concurrent fragment assemblies\n    static let bleHighDegreeThreshold: Int = 6              // For adaptive TTL/probabilistic relays\n    static let bleMaxConcurrentTransfers: Int = 2           // Limit simultaneous large media sends\n    static let bleFragmentRelayMinDelayMs: Int = 8          // Faster forwarding for media fragments\n    static let bleFragmentRelayMaxDelayMs: Int = 25         // Upper jitter bound for fragment relays\n    static let bleFragmentRelayTtlCap: UInt8 = 5            // Clamp fragment TTL to contain floods\n\n    // UI / Storage Caps\n    static let privateChatCap: Int = 1337\n    static let meshTimelineCap: Int = 1337\n    static let geoTimelineCap: Int = 1337\n    static let contentLRUCap: Int = 2000\n\n    // Timers\n    static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes\n    static let networkNotificationCooldownSeconds: TimeInterval = 300 // 5 minutes\n    static let basePublicFlushInterval: TimeInterval = 0.08  // ~12.5 fps batching\n\n    // BLE duty/announce/connect\n    static let bleConnectRateLimitInterval: TimeInterval = 0.5\n    static let bleMaxCentralLinks: Int = 6\n    static let bleDutyOnDuration: TimeInterval = 5.0\n    static let bleDutyOffDuration: TimeInterval = 10.0\n    static let bleAnnounceMinInterval: TimeInterval = 1.0\n\n    // BLE discovery/quality thresholds\n    static let bleDynamicRSSIThresholdDefault: Int = -90\n    static let bleConnectionCandidatesMax: Int = 100\n    static let blePendingWriteBufferCapBytes: Int = 1_000_000\n    static let bleNotificationAssemblerHardCapBytes: Int = 8 * 1024 * 1024\n    static let bleAssemblerStallResetMs: Int = 250\n    static let blePendingNotificationsCapCount: Int = 128\n    static let bleNotificationRetryDelayMs: Int = 25\n    static let bleNotificationRetryMaxAttempts: Int = 80\n\n    // Nostr\n    static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second\n\n    // UI thresholds\n    static let uiLateInsertThreshold: TimeInterval = 15.0\n    // Geohash public chats are more sensitive to ordering; use a tighter threshold\n    static let uiLateInsertThresholdGeo: TimeInterval = 0.0\n    static let uiProcessedNostrEventsCap: Int = 2000\n    static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60\n    \n    // UI rate limiters (token buckets)\n    static let uiSenderRateBucketCapacity: Double = 5\n    static let uiSenderRateBucketRefillPerSec: Double = 1.0\n    static let uiContentRateBucketCapacity: Double = 3\n    static let uiContentRateBucketRefillPerSec: Double = 0.5\n\n    // UI sleeps/delays\n    static let uiStartupInitialDelaySeconds: TimeInterval = 1.0\n    static let uiStartupShortSleepNs: UInt64 = 200_000_000\n    static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0\n    static let uiAsyncShortSleepNs: UInt64 = 100_000_000\n    static let uiAsyncMediumSleepNs: UInt64 = 500_000_000\n    static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1\n    static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5\n    static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15\n    static let uiScrollThrottleSeconds: TimeInterval = 0.5\n    static let uiAnimationShortSeconds: TimeInterval = 0.15\n    static let uiAnimationMediumSeconds: TimeInterval = 0.2\n    static let uiAnimationSidebarSeconds: TimeInterval = 0.25\n    static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60\n    static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0\n\n    // BLE maintenance & thresholds\n    static let bleMaintenanceInterval: TimeInterval = 5.0\n    static let bleMaintenanceLeewaySeconds: Int = 1\n    static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60\n    static let bleRecentTimeoutWindowSeconds: TimeInterval = 60\n    static let bleRecentTimeoutCountThreshold: Int = 3\n    static let bleRSSIIsolatedBase: Int = -90\n    static let bleRSSIIsolatedRelaxed: Int = -92\n    static let bleRSSIConnectedThreshold: Int = -85\n    static let bleRSSIHighTimeoutThreshold: Int = -80\n    // How long without seeing traffic before we sanity-check the direct link\n    // Lowered to make connected→reachable icon changes react faster when walking out of range\n    static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0\n    // How long to retain a peer as \"reachable\" (not directly connected) since lastSeen\n    static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0    // 21s for verified/favorites\n    static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0  // 21s for unknown/unverified\n    static let bleFragmentLifetimeSeconds: TimeInterval = 30.0\n    static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0\n    static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0\n    static let bleRecentPacketWindowSeconds: TimeInterval = 30.0\n    static let bleRecentPacketWindowMaxCount: Int = 100\n    // Keep scanning fully ON when we saw traffic very recently\n    static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0\n    static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05\n    static let bleExpectedWritePerFragmentMs: Int = 20\n    static let bleExpectedWriteMaxMs: Int = 5000\n    // Fragment pacing: Conservative spacing to prevent BLE buffer overflow\n    // Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery\n    static let bleFragmentSpacingMs: Int = 30\n    static let bleFragmentSpacingDirectedMs: Int = 25\n    static let bleAnnounceIntervalSeconds: TimeInterval = 4.0\n    static let bleDutyOnDurationDense: TimeInterval = 3.0\n    static let bleDutyOffDurationDense: TimeInterval = 15.0\n    static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 30.0\n    static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 15.0\n    static let bleConnectedAnnounceJitterDense: TimeInterval = 8.0\n    static let bleConnectedAnnounceJitterSparse: TimeInterval = 4.0\n\n    // Location\n    static let locationDistanceFilterMeters: Double = 1000\n    // Live (channel sheet open) distance threshold for meaningful updates\n    static let locationDistanceFilterLiveMeters: Double = 10.0\n    static let locationLiveRefreshInterval: TimeInterval = 5.0\n\n    // Notifications (geohash)\n    static let uiGeoNotifyCooldownSeconds: TimeInterval = 60.0\n    static let uiGeoNotifySnippetMaxLen: Int = 80\n\n    // Nostr geohash\n    static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600\n    static let nostrGeohashInitialLimit: Int = 200\n    static let nostrGeoRelayCount: Int = 5\n    static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300\n    static let nostrGeohashSampleLimit: Int = 100\n    static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400\n\n    // Nostr helpers\n    static let nostrShortKeyDisplayLength: Int = 8\n    static let nostrConvKeyPrefixLength: Int = 16\n\n    // Compression\n    static let compressionThresholdBytes: Int = 100\n\n    // Message deduplication\n    static let messageDedupMaxAgeSeconds: TimeInterval = 300\n    static let messageDedupMaxCount: Int = 1000\n\n    // Verification QR\n    static let verificationQRMaxAgeSeconds: TimeInterval = 5 * 60\n\n    // Nostr relay backoff\n    static let nostrRelayInitialBackoffSeconds: TimeInterval = 1.0\n    static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0\n    static let nostrRelayBackoffMultiplier: Double = 2.0\n    static let nostrRelayMaxReconnectAttempts: Int = 10\n    static let nostrRelayDefaultFetchLimit: Int = 100\n\n    // Geo relay directory\n    static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24\n    static let geoRelayRefreshCheckIntervalSeconds: TimeInterval = 60 * 60\n    static let geoRelayRetryInitialSeconds: TimeInterval = 60\n    static let geoRelayRetryMaxSeconds: TimeInterval = 60 * 60\n\n    // BLE operational delays\n    static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6\n    static let bleConnectTimeoutSeconds: TimeInterval = 8.0\n    static let bleRestartScanDelaySeconds: TimeInterval = 0.1\n    static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.05\n    static let blePostAnnounceDelaySeconds: TimeInterval = 0.4\n    static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15\n\n    // BCH-01-004: Rate-limiting for subscription-triggered announces\n    // Prevents rapid enumeration attacks by rate-limiting announce responses\n    static let bleSubscriptionRateLimitMinSeconds: TimeInterval = 2.0       // Minimum interval between announces per central\n    static let bleSubscriptionRateLimitBackoffFactor: Double = 2.0          // Exponential backoff multiplier\n    static let bleSubscriptionRateLimitMaxBackoffSeconds: TimeInterval = 30.0  // Maximum backoff period\n    static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0   // Window for tracking subscription attempts\n    static let bleSubscriptionRateLimitMaxAttempts: Int = 5                 // Max attempts before extended cooldown\n\n    // Store-and-forward for directed packets at relays\n    static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0\n\n    // Log/UI debounce windows\n    // Shorter debounce so UI reacts faster while still suppressing duplicate callbacks\n    static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9\n    static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0\n\n    // Weak-link cooldown after connection timeouts\n    static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0\n    static let bleWeakLinkRSSICutoff: Int = -90\n\n    // Content hashing / formatting\n    static let contentKeyPrefixLength: Int = 256\n    static let uiLongMessageLengthThreshold: Int = 2000\n    static let uiVeryLongTokenThreshold: Int = 512\n    static let uiLongMessageLineLimit: Int = 30\n    static let uiFingerprintSampleCount: Int = 3\n    \n    // UI swipe/gesture thresholds\n    static let uiBackSwipeTranslationLarge: CGFloat = 50\n    static let uiBackSwipeTranslationSmall: CGFloat = 30\n    static let uiBackSwipeVelocityThreshold: CGFloat = 300\n    \n    // UI color tuning\n    static let uiColorHueAvoidanceDelta: Double = 0.05\n    static let uiColorHueOffset: Double = 0.12\n    // Peer list palette\n    static let uiPeerPaletteSlots: Int = 36\n    static let uiPeerPaletteRingBrightnessDeltaLight: Double = 0.07\n    static let uiPeerPaletteRingBrightnessDeltaDark: Double = -0.07\n\n    // UI windowing (infinite scroll)\n    static let uiWindowInitialCountPublic: Int = 300\n    static let uiWindowInitialCountPrivate: Int = 300\n    static let uiWindowStepCount: Int = 200\n\n    // Share extension\n    static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0\n    static let uiShareAcceptWindowSeconds: TimeInterval = 30.0\n    static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60\n\n    // Gossip Sync Configuration\n    static let syncSeenCapacity: Int = 1000\n    static let syncGCSMaxBytes: Int = 400\n    static let syncGCSTargetFpr: Double = 0.01\n    static let syncMaxMessageAgeSeconds: TimeInterval = 900\n    static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0\n    static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0\n    static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0\n    static let syncFragmentCapacity: Int = 600\n    static let syncFileTransferCapacity: Int = 200\n    static let syncFragmentIntervalSeconds: TimeInterval = 30.0\n    static let syncFileTransferIntervalSeconds: TimeInterval = 60.0\n    static let syncMessageIntervalSeconds: TimeInterval = 15.0\n}\n"
  },
  {
    "path": "bitchat/Services/UnifiedPeerService.swift",
    "content": "//\n//  UnifiedPeerService.swift\n//  bitchat\n//\n//  Unified peer state management combining mesh connectivity and favorites\n//  This is free and unencumbered software released into the public domain.\n//\n\nimport BitLogger\nimport Foundation\nimport Combine\nimport SwiftUI\n\n/// Single source of truth for peer state, combining mesh connectivity and favorites\n@MainActor\nfinal class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {\n    \n    // MARK: - Published Properties\n    \n    @Published private(set) var peers: [BitchatPeer] = []\n    @Published private(set) var connectedPeerIDs: Set<PeerID> = []\n    @Published private(set) var favorites: [BitchatPeer] = []\n    @Published private(set) var mutualFavorites: [BitchatPeer] = []\n    \n    // MARK: - Private Properties\n    \n    private var peerIndex: [PeerID: BitchatPeer] = [:]\n    private var fingerprintCache: [PeerID: String] = [:]\n    private let meshService: Transport\n    private let idBridge: NostrIdentityBridge\n    private let identityManager: SecureIdentityStateManagerProtocol\n    weak var messageRouter: MessageRouter?\n    private let favoritesService = FavoritesPersistenceService.shared\n    private var cancellables = Set<AnyCancellable>()\n    \n    // MARK: - Initialization\n    \n    init(\n        meshService: Transport,\n        idBridge: NostrIdentityBridge,\n        identityManager: SecureIdentityStateManagerProtocol\n    ) {\n        self.meshService = meshService\n        self.idBridge = idBridge\n        self.identityManager = identityManager\n        \n        // Subscribe to changes from both services\n        setupSubscriptions()\n        \n        // Perform initial update\n        Task { @MainActor in\n            updatePeers()\n        }\n    }\n    \n    // MARK: - Setup\n    \n    private func setupSubscriptions() {\n        // Subscribe to mesh peer updates via delegate (preferred over publishers)\n        meshService.peerEventsDelegate = self\n        \n        // Also listen for favorite change notifications\n        NotificationCenter.default.publisher(for: .favoriteStatusChanged)\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] _ in\n                self?.updatePeers()\n            }\n            .store(in: &cancellables)\n    }\n\n    // TransportPeerEventsDelegate\n    func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) {\n        updatePeers()\n    }\n    \n    // MARK: - Core Update Logic\n    \n    private func updatePeers() {\n        let meshPeers = meshService.currentPeerSnapshots()\n        // If we have no direct links at all, peers should not be marked reachable\n        // \"Reachable\" means mesh-attached via at least one live link.\n        let hasAnyConnected = meshPeers.contains { $0.isConnected }\n        let favorites = favoritesService.favorites\n        \n        var enrichedPeers: [BitchatPeer] = []\n        var connected: Set<PeerID> = []\n        var addedPeerIDs: Set<PeerID> = []\n        \n        // Phase 1: Add all mesh peers (connected and reachable)\n        for peerInfo in meshPeers {\n            let peerID = peerInfo.peerID\n            guard peerID != meshService.myPeerID else { continue }  // Never add self\n            \n            let peer = buildPeerFromMesh(\n                peerInfo: peerInfo,\n                favorites: favorites,\n                meshAttached: hasAnyConnected\n            )\n            \n            enrichedPeers.append(peer)\n            if peer.isConnected { connected.insert(peerID) }\n            addedPeerIDs.insert(peerID)\n            \n            // Update fingerprint cache\n            if let publicKey = peerInfo.noisePublicKey {\n                fingerprintCache[peerID] = publicKey.sha256Fingerprint()\n            }\n        }\n        \n        // Phase 2: Add offline favorites that we actively favorite\n        for (favoriteKey, favorite) in favorites where favorite.isFavorite {\n            let peerID = PeerID(hexData: favoriteKey)\n            \n            // Skip if already added (connected peer)\n            if addedPeerIDs.contains(peerID) { continue }\n            \n            // Skip if connected under different ID but same nickname\n            let isConnectedByNickname = enrichedPeers.contains { \n                $0.nickname == favorite.peerNickname && $0.isConnected \n            }\n            if isConnectedByNickname { continue }\n            \n            let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)\n            enrichedPeers.append(peer)\n            addedPeerIDs.insert(peerID)\n            \n            // Update fingerprint cache\n            fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()\n        }\n        \n        // Phase 3: Sort peers\n        enrichedPeers.sort { lhs, rhs in\n            // Connectivity rank: connected > reachable > others\n            func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) }\n            let lr = rank(lhs), rr = rank(rhs)\n            if lr != rr { return lr > rr }\n            // Then favorites inside same rank\n            if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite }\n            // Finally alphabetical\n            return lhs.displayName < rhs.displayName\n        }\n        \n        // Phase 4: Build subsets and indices\n        var favoritesList: [BitchatPeer] = []\n        var mutualsList: [BitchatPeer] = []\n        var newIndex: [PeerID: BitchatPeer] = [:]\n        \n        for peer in enrichedPeers {\n            newIndex[peer.peerID] = peer\n            \n            if peer.isFavorite {\n                favoritesList.append(peer)\n            }\n            if peer.isMutualFavorite {\n                mutualsList.append(peer)\n            }\n        }\n        \n        // Phase 5: Filter out offline non-mutual peers and update published properties\n        let filtered = enrichedPeers.filter { p in\n            p.isConnected || p.isReachable || p.isMutualFavorite\n        }\n        self.peers = filtered\n        self.connectedPeerIDs = connected\n        self.favorites = favoritesList\n        self.mutualFavorites = mutualsList\n        self.peerIndex = newIndex\n        \n        // Log summary (commented out to reduce noise)\n        // let connectedCount = connected.count\n        // let offlineCount = enrichedPeers.count - connectedCount\n        // Peer update: \\(enrichedPeers.count) total (\\(connectedCount) connected, \\(offlineCount) offline)\n    }\n    \n    // MARK: - Peer Building Helpers\n    \n    private func buildPeerFromMesh(\n        peerInfo: TransportPeerSnapshot,\n        favorites: [Data: FavoritesPersistenceService.FavoriteRelationship],\n        meshAttached: Bool\n    ) -> BitchatPeer {\n        // Determine reachability based on lastSeen and identity trust\n        let now = Date()\n        let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()\n        let isVerified = fingerprint.map { identityManager.isVerified(fingerprint: $0) } ?? false\n        let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false\n        let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds\n        // A peer is reachable if we recently saw them AND we are attached to the mesh\n        let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention\n        let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)\n\n        var peer = BitchatPeer(\n            peerID: peerInfo.peerID,\n            noisePublicKey: peerInfo.noisePublicKey ?? Data(),\n            nickname: peerInfo.nickname,\n            lastSeen: peerInfo.lastSeen,\n            isConnected: peerInfo.isConnected,\n            isReachable: isReachable\n        )\n        \n        // Check for favorite status\n        if let noiseKey = peerInfo.noisePublicKey,\n           let favoriteStatus = favorites[noiseKey] {\n            peer.favoriteStatus = favoriteStatus\n            peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey\n        }\n        \n        return peer\n    }\n    \n    private func buildPeerFromFavorite(\n        favorite: FavoritesPersistenceService.FavoriteRelationship,\n        peerID: PeerID\n    ) -> BitchatPeer {\n        var peer = BitchatPeer(\n            peerID: peerID,\n            noisePublicKey: favorite.peerNoisePublicKey,\n            nickname: favorite.peerNickname,\n            lastSeen: favorite.lastUpdated,\n            isConnected: false,\n            isReachable: false\n        )\n        \n        peer.favoriteStatus = favorite\n        peer.nostrPublicKey = favorite.peerNostrPublicKey\n        \n        return peer\n    }\n    \n    // MARK: - Public Methods\n    \n    /// Get peer by ID\n    func getPeer(by peerID: PeerID) -> BitchatPeer? {\n        return peerIndex[peerID]\n    }\n    \n    /// Get peer ID for nickname\n    func getPeerID(for nickname: String) -> PeerID? {\n        for peer in peers {\n            if peer.displayName == nickname || peer.nickname == nickname {\n                return peer.peerID\n            }\n        }\n        return nil\n    }\n    \n    /// Check if peer is blocked\n    func isBlocked(_ peerID: PeerID) -> Bool {\n        // Get fingerprint\n        guard let fingerprint = getFingerprint(for: peerID) else { return false }\n        \n        // Check SecureIdentityStateManager for block status\n        if let identity = identityManager.getSocialIdentity(for: fingerprint) {\n            return identity.isBlocked\n        }\n        \n        return false\n    }\n    \n    /// Toggle favorite status\n    func toggleFavorite(_ peerID: PeerID) {\n        guard let peer = getPeer(by: peerID) else {\n            SecureLogger.warning(\"⚠️ Cannot toggle favorite - peer not found: \\(peerID)\", category: .session)\n            return \n        }\n        \n        let wasFavorite = peer.isFavorite\n        \n        // Get the actual nickname for logging and saving\n        var actualNickname = peer.nickname\n        \n        // Debug logging to understand the issue\n        SecureLogger.debug(\"🔍 Toggle favorite - peer.nickname: '\\(peer.nickname)', peer.displayName: '\\(peer.displayName)', peerID: \\(peerID)\", category: .session)\n        \n        if actualNickname.isEmpty {\n            // Try to get from mesh service's current peer list\n            if let meshPeerNickname = meshService.peerNickname(peerID: peerID) {\n                actualNickname = meshPeerNickname\n                SecureLogger.debug(\"🔍 Got nickname from mesh service: '\\(actualNickname)'\", category: .session)\n            }\n        }\n        \n        // Use displayName as fallback (which shows ID prefix if nickname is empty)\n        let finalNickname = actualNickname.isEmpty ? peer.displayName : actualNickname\n        \n        if wasFavorite {\n            // Remove favorite\n            favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)\n        } else {\n            // Get or derive peer's Nostr public key if not already known\n            var peerNostrKey = peer.nostrPublicKey\n            if peerNostrKey == nil {\n                // Try to get from NostrIdentityBridge association\n                peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)\n            }\n            \n            // Add favorite\n            favoritesService.addFavorite(\n                peerNoisePublicKey: peer.noisePublicKey,\n                peerNostrPublicKey: peerNostrKey,\n                peerNickname: finalNickname\n            )\n        }\n        \n        // Log the final nickname being saved\n        SecureLogger.debug(\"⭐️ Toggled favorite for '\\(finalNickname)' (peerID: \\(peerID), was: \\(wasFavorite), now: \\(!wasFavorite))\", category: .session)\n        \n        // Send favorite notification to the peer via router (mesh or Nostr)\n        if let router = messageRouter {\n            router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)\n        } else {\n            // Fallback to mesh-only if router not yet wired\n            meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)\n        }\n        \n        // Force update of peers to reflect the change\n        updatePeers()\n        \n        // Force UI update by notifying SwiftUI directly\n        DispatchQueue.main.async { [weak self] in\n            self?.objectWillChange.send()\n        }\n    }\n    \n    func getFingerprint(for peerID: PeerID) -> String? {\n        // Check cache first\n        if let cached = fingerprintCache[peerID] {\n            return cached\n        }\n        \n        // Try to get from mesh service\n        if let fingerprint = meshService.getFingerprint(for: peerID) {\n            fingerprintCache[peerID] = fingerprint\n            return fingerprint\n        }\n        \n        // Try to get from peer's public key\n        if let peer = getPeer(by: peerID) {\n            let fingerprint = peer.noisePublicKey.sha256Fingerprint()\n            fingerprintCache[peerID] = fingerprint\n            return fingerprint\n        }\n        \n        return nil\n    }\n    \n    // MARK: - Compatibility Methods (for easy migration)\n    \n    var allPeers: [BitchatPeer] { peers }\n    var connectedPeers: Set<PeerID> { connectedPeerIDs }\n    var favoritePeers: Set<String> {\n        Set(favorites.compactMap { getFingerprint(for: $0.peerID) })\n    }\n    var blockedUsers: Set<String> {\n        Set(peers.compactMap { peer in\n            isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil\n        })\n    }\n}\n"
  },
  {
    "path": "bitchat/Services/VerificationService.swift",
    "content": "import Foundation\n\n/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.\nfinal class VerificationService {\n    static let shared = VerificationService()\n\n    // Injected Noise service from the running transport (do NOT create new BLEService)\n    private var noise: NoiseEncryptionService?\n    func configure(with noise: NoiseEncryptionService) { self.noise = noise }\n\n    /// Encapsulates the data encoded into a verification QR\n    struct VerificationQR: Codable {\n        let v: Int\n        let noiseKeyHex: String\n        let signKeyHex: String\n        let npub: String?\n        let nickname: String\n        let ts: Int64\n        let nonceB64: String\n        var sigHex: String\n\n        static let context = \"bitchat-verify-v1\"\n\n        /// Canonical bytes used for signature (deterministic ordering)\n        func canonicalBytes() -> Data {\n            var out = Data()\n            func appendField(_ s: String) {\n                let d = s.data(using: .utf8) ?? Data()\n                out.append(UInt8(min(d.count, 255)))\n                out.append(d.prefix(255))\n            }\n            appendField(Self.context)\n            appendField(String(v))\n            appendField(noiseKeyHex.lowercased())\n            appendField(signKeyHex.lowercased())\n            appendField(npub ?? \"\")\n            appendField(nickname)\n            appendField(String(ts))\n            appendField(nonceB64)\n            return out\n        }\n\n        func toURLString() -> String {\n            var comps = URLComponents()\n            comps.scheme = \"bitchat\"\n            comps.host = \"verify\"\n            comps.queryItems = [\n                URLQueryItem(name: \"v\", value: String(v)),\n                URLQueryItem(name: \"noise\", value: noiseKeyHex),\n                URLQueryItem(name: \"sign\", value: signKeyHex),\n                URLQueryItem(name: \"nick\", value: nickname),\n                URLQueryItem(name: \"ts\", value: String(ts)),\n                URLQueryItem(name: \"nonce\", value: nonceB64),\n                URLQueryItem(name: \"sig\", value: sigHex)\n            ] + (npub != nil ? [URLQueryItem(name: \"npub\", value: npub)] : [])\n            return comps.string ?? \"\"\n        }\n\n        static func fromURL(_ url: URL) -> VerificationQR? {\n            guard url.scheme == \"bitchat\", url.host == \"verify\",\n                  let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil }\n            func val(_ name: String) -> String? { items.first(where: { $0.name == name })?.value }\n            guard let vStr = val(\"v\"), let v = Int(vStr),\n                  let noise = val(\"noise\"), let sign = val(\"sign\"),\n                  let nick = val(\"nick\"), let tsStr = val(\"ts\"), let ts = Int64(tsStr),\n                  let nonce = val(\"nonce\"), let sig = val(\"sig\") else { return nil }\n            return VerificationQR(v: v, noiseKeyHex: noise, signKeyHex: sign, npub: val(\"npub\"), nickname: nick, ts: ts, nonceB64: nonce, sigHex: sig)\n        }\n    }\n\n    // MARK: - Public API\n\n    /// Build a signed QR string for the current identity\n    func buildMyQRString(nickname: String, npub: String?) -> String? {\n        // Simple short-lived cache to speed up sheet opening\n        struct Cache { static var last: (nick: String, npub: String?, builtAt: Date, value: String)? }\n        if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {\n            return c.value\n        }\n        guard let noise = noise else { return nil }\n        let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()\n        let signKey = noise.getSigningPublicKeyData().hexEncodedString()\n        let ts = Int64(Date().timeIntervalSince1970)\n        var nonce = Data(count: 16)\n        _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }\n        let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: \"+\", with: \"-\").replacingOccurrences(of: \"/\", with: \"_\").replacingOccurrences(of: \"=\", with: \"\")\n        let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: \"\")\n        let msg = payload.canonicalBytes()\n        guard let sig = noise.signData(msg) else { return nil }\n        let signed = VerificationQR(v: payload.v,\n                                    noiseKeyHex: payload.noiseKeyHex,\n                                    signKeyHex: payload.signKeyHex,\n                                    npub: payload.npub,\n                                    nickname: payload.nickname,\n                                    ts: payload.ts,\n                                    nonceB64: payload.nonceB64,\n                                    sigHex: sig.hexEncodedString())\n        let out = signed.toURLString()\n        Cache.last = (nickname, npub, Date(), out)\n        return out\n    }\n\n    /// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)\n    func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? {\n        guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }\n        // Freshness\n        let now = Date().timeIntervalSince1970\n        if now - Double(qr.ts) > maxAge { return nil }\n        // Verify signature using embedded ed25519 signKey\n        guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }\n        guard let noise = noise else { return nil }\n        let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)\n        return ok ? qr : nil\n    }\n\n    // MARK: - Noise payloads (scaffold only)\n\n    func buildVerifyChallenge(noiseKeyHex: String, nonceA: Data) -> Data {\n        // TLV: [0x01 len noiseKeyHex ascii] [0x02 len nonceA]\n        var tlv = Data()\n        let n0: [UInt8] = [0x01, UInt8(min(noiseKeyHex.count, 255))]\n        tlv.append(contentsOf: n0)\n        tlv.append(noiseKeyHex.data(using: .utf8)!.prefix(255))\n        tlv.append(0x02)\n        tlv.append(UInt8(min(nonceA.count, 255)))\n        tlv.append(nonceA.prefix(255))\n        return NoisePayload(type: .verifyChallenge, data: tlv).encode()\n    }\n\n    func buildVerifyResponse(noiseKeyHex: String, nonceA: Data) -> Data? {\n        // Sign context: verify-response | noiseKeyHex | nonceA\n        var msg = Data(\"bitchat-verify-resp-v1\".utf8)\n        let nk = noiseKeyHex.data(using: .utf8) ?? Data()\n        msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))\n        msg.append(nonceA)\n        guard let noise = noise, let sig = noise.signData(msg) else { return nil }\n        var tlv = Data()\n        tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))\n        tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))\n        tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255))\n        return NoisePayload(type: .verifyResponse, data: tlv).encode()\n    }\n\n    func parseVerifyChallenge(_ data: Data) -> (noiseKeyHex: String, nonceA: Data)? {\n        var idx = 0\n        func take(_ n: Int) -> Data? {\n            guard idx + n <= data.count else { return nil }\n            let d = data[idx..<(idx+n)]\n            idx += n\n            return Data(d)\n        }\n        // Expect type already stripped; we receive only TLV here\n        // TLV 0x01 noiseKeyHex\n        guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),\n              let noiseStr = String(data: s1, encoding: .utf8) else { return nil }\n        // TLV 0x02 nonceA\n        guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }\n        return (noiseStr, nA)\n    }\n\n    func parseVerifyResponse(_ data: Data) -> (noiseKeyHex: String, nonceA: Data, signature: Data)? {\n        var idx = 0\n        func take(_ n: Int) -> Data? {\n            guard idx + n <= data.count else { return nil }\n            let d = data[idx..<(idx+n)]\n            idx += n\n            return Data(d)\n        }\n        guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),\n              let noiseStr = String(data: s1, encoding: .utf8) else { return nil }\n        guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }\n        guard let t3 = take(1), t3[0] == 0x03, let l3 = take(1), let sig = take(Int(l3[0])) else { return nil }\n        return (noiseStr, nA, sig)\n    }\n\n    func verifyResponseSignature(noiseKeyHex: String, nonceA: Data, signature: Data, signerPublicKeyHex: String) -> Bool {\n        var msg = Data(\"bitchat-verify-resp-v1\".utf8)\n        let nk = noiseKeyHex.data(using: .utf8) ?? Data()\n        msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))\n        msg.append(nonceA)\n        guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }\n        return noise.verifySignature(signature, for: msg, publicKey: pub)\n    }\n}\n"
  },
  {
    "path": "bitchat/Sync/GCSFilter.swift",
    "content": "import Foundation\nimport CryptoKit\n\n// Golomb-Coded Set (GCS) filter utilities for sync.\n// Hashing:\n//  - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.\n//  - Map to [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas.\n// Encoding (v1):\n//  - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.\n//  - 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<<P)-1).\n//  - Bitstream is MSB-first within each byte.\nenum GCSFilter {\n    struct Params { let p: Int; let m: UInt32; let data: Data }\n\n    // Derive P from FPR (~ 1 / 2^P)\n    static func deriveP(targetFpr: Double) -> Int {\n        let f = max(0.000001, min(0.25, targetFpr))\n        // ceil(log2(1/f))\n        let p = Int(ceil(log2(1.0 / f)))\n        return max(1, p)\n    }\n\n    // Estimate max elements that fit in size bytes: bits per element ~= P + 2 (approx)\n    static func estimateMaxElements(sizeBytes: Int, p: Int) -> Int {\n        let bits = max(8, sizeBytes * 8)\n        let per = max(3, p + 2)\n        return max(1, bits / per)\n    }\n\n    static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {\n        let p = deriveP(targetFpr: targetFpr)\n        guard !ids.isEmpty else {\n            return Params(p: p, m: 1, data: Data())\n        }\n\n        let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)\n        let selected = Array(ids.prefix(cap))\n        let range = max(1, hashRange(count: selected.count, p: p))\n        let modulo = UInt64(range)\n\n        var mapped = selected\n            .map { h64($0) }\n            .map { mapHash($0, modulo: modulo) }\n            .sorted()\n        mapped = normalizeMappedValues(mapped, modulo: modulo)\n\n        if mapped.isEmpty {\n            return Params(p: p, m: range, data: Data())\n        }\n\n        var encoded = encode(sorted: mapped, p: p)\n        var trimmedCount = mapped.count\n\n        while encoded.count > maxBytes && trimmedCount > 0 {\n            if trimmedCount == 1 {\n                mapped.removeAll()\n                encoded = Data()\n                break\n            }\n            trimmedCount = max(1, (trimmedCount * 9) / 10)\n            mapped = Array(mapped.prefix(trimmedCount))\n            encoded = encode(sorted: mapped, p: p)\n        }\n\n        return Params(p: p, m: range, data: encoded)\n    }\n\n    static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {\n        var values: [UInt64] = []\n        let reader = BitReader(data)\n        var acc: UInt64 = 0\n        while true {\n            guard let q = reader.readUnary() else { break }\n            guard let r = reader.readBits(count: p) else { break }\n            let x = (UInt64(q) << UInt64(p)) + UInt64(r) + 1\n            acc &+= x\n            if acc >= UInt64(m) { break }\n            values.append(acc)\n        }\n        return values\n    }\n\n    static func contains(sortedValues: [UInt64], candidate: UInt64) -> Bool {\n        var lo = 0\n        var hi = sortedValues.count - 1\n        while lo <= hi {\n            let mid = (lo + hi) >> 1\n            let v = sortedValues[mid]\n            if v == candidate { return true }\n            if v < candidate { lo = mid + 1 } else { hi = mid - 1 }\n        }\n        return false\n    }\n\n    static func bucket(for id: Data, modulus m: UInt32) -> UInt64 {\n        let modulo = UInt64(max(1, m))\n        guard modulo > 1 else { return 0 }\n        return mapHash(h64(id), modulo: modulo)\n    }\n\n    private static func h64(_ id16: Data) -> UInt64 {\n        var hasher = SHA256()\n        hasher.update(data: id16)\n        let d = hasher.finalize()\n        let db = Data(d)\n        var x: UInt64 = 0\n        let take = min(8, db.count)\n        for i in 0..<take { x = (x << 8) | UInt64(db[i]) }\n        return x & 0x7fff_ffff_ffff_ffff\n    }\n\n    private static func hashRange(count: Int, p: Int) -> UInt32 {\n        guard count > 0 else { return 1 }\n        if p >= 64 { return UInt32.max }\n        let multiplier = UInt64(1) << UInt64(p)\n        let (product, overflow) = UInt64(count).multipliedReportingOverflow(by: multiplier)\n        if overflow { return UInt32.max }\n        if product == 0 { return 1 }\n        return product > UInt64(UInt32.max) ? UInt32.max : UInt32(product)\n    }\n\n    private static func mapHash(_ hash: UInt64, modulo: UInt64) -> UInt64 {\n        guard modulo > 1 else { return 0 }\n        let value = hash % modulo\n        if value == 0 { return 1 }\n        return value\n    }\n\n    private static func normalizeMappedValues(_ values: [UInt64], modulo: UInt64) -> [UInt64] {\n        guard modulo > 1 else { return [] }\n        guard !values.isEmpty else { return [] }\n        var result: [UInt64] = []\n        result.reserveCapacity(values.count)\n        var last: UInt64 = 0\n        for value in values {\n            let normalized = min(value, modulo - 1)\n            if normalized > last {\n                result.append(normalized)\n                last = normalized\n            }\n        }\n        return result\n    }\n\n    private static func encode(sorted: [UInt64], p: Int) -> Data {\n        let writer = BitWriter()\n        var prev: UInt64 = 0\n        let mask: UInt64 = (p >= 64) ? ~0 : ((1 << UInt64(p)) - 1)\n        for v in sorted {\n            let delta = v &- prev\n            prev = v\n            let x = delta\n            let q = (x &- 1) >> UInt64(p)\n            let r = (x &- 1) & mask\n            // unary q ones then zero\n            if q > 0 { writer.writeOnes(count: Int(q)) }\n            writer.writeBit(0)\n            writer.writeBits(value: r, count: p)\n        }\n        return writer.toData()\n    }\n\n    // MARK: - Bit helpers (MSB-first)\n    private final class BitWriter {\n        private var buf = Data()\n        private var cur: UInt8 = 0\n        private var nbits: Int = 0\n        func writeBit(_ bit: Int) { // 0 or 1\n            cur = UInt8((Int(cur) << 1) | (bit & 1))\n            nbits += 1\n            if nbits == 8 {\n                buf.append(cur)\n                cur = 0; nbits = 0\n            }\n        }\n        func writeOnes(count: Int) {\n            guard count > 0 else { return }\n            for _ in 0..<count { writeBit(1) }\n        }\n        func writeBits(value: UInt64, count: Int) {\n            guard count > 0 else { return }\n            for i in stride(from: count - 1, through: 0, by: -1) {\n                let bit = Int((value >> UInt64(i)) & 1)\n                writeBit(bit)\n            }\n        }\n        func toData() -> Data {\n            if nbits > 0 {\n                let rem = UInt8(Int(cur) << (8 - nbits))\n                buf.append(rem)\n                cur = 0; nbits = 0\n            }\n            return buf\n        }\n    }\n\n    private final class BitReader {\n        private let data: Data\n        private var idx: Int = 0\n        private var cur: UInt8 = 0\n        private var left: Int = 0\n        init(_ data: Data) {\n            self.data = data\n            if !data.isEmpty {\n                cur = data[0]\n                left = 8\n            }\n        }\n        func readBit() -> Int? {\n            if idx >= data.count { return nil }\n            let bit = (Int(cur) >> 7) & 1\n            cur = UInt8((Int(cur) << 1) & 0xFF)\n            left -= 1\n            if left == 0 {\n                idx += 1\n                if idx < data.count { cur = data[idx]; left = 8 }\n            }\n            return bit\n        }\n        func readUnary() -> Int? {\n            var q = 0\n            while true {\n                guard let b = readBit() else { return nil }\n                if b == 1 { q += 1 } else { break }\n            }\n            return q\n        }\n        func readBits(count: Int) -> UInt64? {\n            var v: UInt64 = 0\n            for _ in 0..<count {\n                guard let b = readBit() else { return nil }\n                v = (v << 1) | UInt64(b)\n            }\n            return v\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Sync/GossipSyncManager.swift",
    "content": "import Foundation\nimport BitLogger\n\n// Gossip-based sync manager using on-demand GCS filters\nfinal class GossipSyncManager {\n    protocol Delegate: AnyObject {\n        func sendPacket(_ packet: BitchatPacket)\n        func sendPacket(to peerID: PeerID, packet: BitchatPacket)\n        func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket\n        func getConnectedPeers() -> [PeerID]\n    }\n\n    private struct PacketStore {\n        private(set) var packets: [String: BitchatPacket] = [:]\n        private(set) var order: [String] = []\n\n        mutating func insert(idHex: String, packet: BitchatPacket, capacity: Int) {\n            guard capacity > 0 else { return }\n            if packets[idHex] != nil {\n                packets[idHex] = packet\n                return\n            }\n            packets[idHex] = packet\n            order.append(idHex)\n            while order.count > capacity {\n                let victim = order.removeFirst()\n                packets.removeValue(forKey: victim)\n            }\n        }\n\n        func allPackets(isFresh: (BitchatPacket) -> Bool) -> [BitchatPacket] {\n            order.compactMap { key in\n                guard let packet = packets[key], isFresh(packet) else { return nil }\n                return packet\n            }\n        }\n\n        mutating func remove(where shouldRemove: (BitchatPacket) -> Bool) {\n            var nextOrder: [String] = []\n            for key in order {\n                guard let packet = packets[key] else { continue }\n                if shouldRemove(packet) {\n                    packets.removeValue(forKey: key)\n                } else {\n                    nextOrder.append(key)\n                }\n            }\n            order = nextOrder\n        }\n\n        mutating func removeExpired(isFresh: (BitchatPacket) -> Bool) {\n            remove { !isFresh($0) }\n        }\n    }\n\n    private struct SyncSchedule {\n        let types: SyncTypeFlags\n        let interval: TimeInterval\n        var lastSent: Date\n    }\n\n    struct Config {\n        var seenCapacity: Int = 1000          // max packets per sync (cap across types)\n        var gcsMaxBytes: Int = 400           // filter size budget (128..1024)\n        var gcsTargetFpr: Double = 0.01      // 1%\n        var maxMessageAgeSeconds: TimeInterval = 900  // 15 min - discard older messages\n        var maintenanceIntervalSeconds: TimeInterval = 30.0\n        var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0\n        var stalePeerTimeoutSeconds: TimeInterval = 60.0\n        var fragmentCapacity: Int = 600\n        var fileTransferCapacity: Int = 200\n        var fragmentSyncIntervalSeconds: TimeInterval = 30.0\n        var fileTransferSyncIntervalSeconds: TimeInterval = 60.0\n        var messageSyncIntervalSeconds: TimeInterval = 15.0\n    }\n\n    private let myPeerID: PeerID\n    private let config: Config\n    private let requestSyncManager: RequestSyncManager\n    weak var delegate: Delegate?\n\n    // Storage: broadcast packets by type, and latest announce per sender\n    private var messages = PacketStore()\n    private var fragments = PacketStore()\n    private var fileTransfers = PacketStore()\n    private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]\n\n    // Timer\n    private var periodicTimer: DispatchSourceTimer?\n    private let queue = DispatchQueue(label: \"mesh.sync\", qos: .utility)\n    private var lastStalePeerCleanup: Date = .distantPast\n    private var syncSchedules: [SyncSchedule] = []\n\n    init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {\n        self.myPeerID = myPeerID\n        self.config = config\n        self.requestSyncManager = requestSyncManager\n        var schedules: [SyncSchedule] = []\n        if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {\n            schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))\n        }\n        if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {\n            schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))\n        }\n        if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {\n            schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))\n        }\n        syncSchedules = schedules\n    }\n\n    func start() {\n        stop()\n        let timer = DispatchSource.makeTimerSource(queue: queue)\n        let interval = max(0.1, config.maintenanceIntervalSeconds)\n        timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))\n        timer.setEventHandler { [weak self] in\n            self?.performPeriodicMaintenance()\n        }\n        timer.resume()\n        periodicTimer = timer\n    }\n\n    func stop() {\n        periodicTimer?.cancel(); periodicTimer = nil\n    }\n\n    func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {\n        queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in\n            guard let self = self else { return }\n            self.sendRequestSync(to: peerID, types: .publicMessages)\n            if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {\n                self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in\n                    self?.sendRequestSync(to: peerID, types: .fragment)\n                }\n            }\n            if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {\n                self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in\n                    self?.sendRequestSync(to: peerID, types: .fileTransfer)\n                }\n            }\n        }\n    }\n\n    func onPublicPacketSeen(_ packet: BitchatPacket) {\n        queue.async { [weak self] in\n            self?._onPublicPacketSeen(packet)\n        }\n    }\n\n    // Helper to check if a packet is within the age threshold\n    private func isPacketFresh(_ packet: BitchatPacket) -> Bool {\n        let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)\n        let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)\n\n        // If current time is less than threshold, accept all (handle clock issues gracefully)\n        guard nowMs >= ageThresholdMs else { return true }\n\n        let cutoffMs = nowMs - ageThresholdMs\n        return packet.timestamp >= cutoffMs\n    }\n\n    private func isAnnouncementFresh(_ packet: BitchatPacket) -> Bool {\n        guard config.stalePeerTimeoutSeconds > 0 else { return true }\n        let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)\n        let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)\n        guard nowMs >= timeoutMs else { return true }\n        let cutoffMs = nowMs - timeoutMs\n        return packet.timestamp >= cutoffMs\n    }\n\n    private func _onPublicPacketSeen(_ packet: BitchatPacket) {\n        guard let messageType = MessageType(rawValue: packet.type) else { return }\n        let isBroadcastRecipient: Bool = {\n            guard let r = packet.recipientID else { return true }\n            return r.count == 8 && r.allSatisfy { $0 == 0xFF }\n        }()\n\n        switch messageType {\n        case .announce:\n            guard isPacketFresh(packet) else { return }\n            guard isAnnouncementFresh(packet) else {\n                let sender = PeerID(hexData: packet.senderID)\n                removeState(for: sender)\n                return\n            }\n            let idHex = PacketIdUtil.computeId(packet).hexEncodedString()\n            let sender = PeerID(hexData: packet.senderID)\n            latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)\n        case .message:\n            guard isBroadcastRecipient else { return }\n            guard isPacketFresh(packet) else { return }\n            let idHex = PacketIdUtil.computeId(packet).hexEncodedString()\n            messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))\n        case .fragment:\n            guard isBroadcastRecipient else { return }\n            guard isPacketFresh(packet) else { return }\n            let idHex = PacketIdUtil.computeId(packet).hexEncodedString()\n            fragments.insert(idHex: idHex, packet: packet, capacity: max(1, config.fragmentCapacity))\n        case .fileTransfer:\n            guard isBroadcastRecipient else { return }\n            guard isPacketFresh(packet) else { return }\n            let idHex = PacketIdUtil.computeId(packet).hexEncodedString()\n            fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))\n        default:\n            break\n        }\n    }\n\n    private func sendPeriodicSync(for types: SyncTypeFlags) {\n        // Unicast sync to connected peers to allow RSR attribution\n        if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {\n            SecureLogger.debug(\"Sending periodic sync to \\(connectedPeers.count) connected peers\", category: .sync)\n            for peerID in connectedPeers {\n                sendRequestSync(to: peerID, types: types)\n            }\n        } else {\n            // Fallback to broadcast (discovery phase)\n            sendRequestSync(for: types)\n        }\n    }\n\n    private func sendRequestSync(for types: SyncTypeFlags) {\n        let payload = buildGcsPayload(for: types)\n        let pkt = BitchatPacket(\n            type: MessageType.requestSync.rawValue,\n            senderID: Data(hexString: myPeerID.id) ?? Data(),\n            recipientID: nil, // broadcast\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: payload,\n            signature: nil,\n            ttl: 0 // local-only\n        )\n        let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt\n        delegate?.sendPacket(signed)\n    }\n\n    private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {\n        // Register the request for RSR validation\n        requestSyncManager.registerRequest(to: peerID)\n        \n        let payload = buildGcsPayload(for: types)\n        var recipient = Data()\n        var temp = peerID.id\n        while temp.count >= 2 && recipient.count < 8 {\n            let hexByte = String(temp.prefix(2))\n            if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }\n            temp = String(temp.dropFirst(2))\n        }\n        let pkt = BitchatPacket(\n            type: MessageType.requestSync.rawValue,\n            senderID: Data(hexString: myPeerID.id) ?? Data(),\n            recipientID: recipient,\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: payload,\n            signature: nil,\n            ttl: 0 // local-only\n        )\n        let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt\n        delegate?.sendPacket(to: peerID, packet: signed)\n    }\n\n    func handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {\n        queue.async { [weak self] in\n            self?._handleRequestSync(from: peerID, request: request)\n        }\n    }\n\n    private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {\n        let requestedTypes = (request.types ?? .publicMessages)\n        // Decode GCS into sorted set and prepare membership checker\n        let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)\n        func mightContain(_ id: Data) -> Bool {\n            let bucket = GCSFilter.bucket(for: id, modulus: request.m)\n            return GCSFilter.contains(sortedValues: sorted, candidate: bucket)\n        }\n\n        if requestedTypes.contains(.announce) {\n            for (_, pair) in latestAnnouncementByPeer {\n                let (idHex, pkt) = pair\n                guard isPacketFresh(pkt) else { continue }\n                let idBytes = Data(hexString: idHex) ?? Data()\n                if !mightContain(idBytes) {\n                    var toSend = pkt\n                    toSend.ttl = 0\n                    toSend.isRSR = true // Mark as solicited response\n                    delegate?.sendPacket(to: peerID, packet: toSend)\n                }\n            }\n        }\n\n        if requestedTypes.contains(.message) {\n            let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)\n            for pkt in toSendMsgs {\n                let idBytes = PacketIdUtil.computeId(pkt)\n                if !mightContain(idBytes) {\n                    var toSend = pkt\n                    toSend.ttl = 0\n                    toSend.isRSR = true // Mark as solicited response\n                    delegate?.sendPacket(to: peerID, packet: toSend)\n                }\n            }\n        }\n\n        if requestedTypes.contains(.fragment) {\n            let frags = fragments.allPackets(isFresh: isPacketFresh)\n            for pkt in frags {\n                let idBytes = PacketIdUtil.computeId(pkt)\n                if !mightContain(idBytes) {\n                    var toSend = pkt\n                    toSend.ttl = 0\n                    toSend.isRSR = true // Mark as solicited response\n                    delegate?.sendPacket(to: peerID, packet: toSend)\n                }\n            }\n        }\n\n        if requestedTypes.contains(.fileTransfer) {\n            let files = fileTransfers.allPackets(isFresh: isPacketFresh)\n            for pkt in files {\n                let idBytes = PacketIdUtil.computeId(pkt)\n                if !mightContain(idBytes) {\n                    var toSend = pkt\n                    toSend.ttl = 0\n                    toSend.isRSR = true // Mark as solicited response\n                    delegate?.sendPacket(to: peerID, packet: toSend)\n                }\n            }\n        }\n    }\n\n    // Build REQUEST_SYNC payload using current candidates and GCS params\n    private func buildGcsPayload(for types: SyncTypeFlags) -> Data {\n        var candidates: [BitchatPacket] = []\n        if types.contains(.announce) {\n            for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {\n                candidates.append(pair.packet)\n            }\n        }\n        if types.contains(.message) {\n            candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))\n        }\n        if types.contains(.fragment) {\n            candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh))\n        }\n        if types.contains(.fileTransfer) {\n            candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))\n        }\n        if candidates.isEmpty {\n            let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)\n            let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)\n            return req.encode()\n        }\n\n        // Sort by timestamp desc\n        candidates.sort { $0.timestamp > $1.timestamp }\n\n        let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)\n        let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)\n        let cap: Int\n        if types == .fragment {\n            cap = max(1, config.fragmentCapacity)\n        } else if types == .fileTransfer {\n            cap = max(1, config.fileTransferCapacity)\n        } else {\n            cap = max(1, config.seenCapacity)\n        }\n        let takeN = min(candidates.count, min(nMax, cap))\n        if takeN <= 0 {\n            let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)\n            return req.encode()\n        }\n        let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }\n        let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)\n        let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)\n        return req.encode()\n    }\n\n    // Periodic cleanup of expired messages and announcements\n    private func cleanupExpiredMessages() {\n        // Remove expired announcements\n        latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in\n            isPacketFresh(pair.packet)\n        }\n\n        messages.removeExpired(isFresh: isPacketFresh)\n        fragments.removeExpired(isFresh: isPacketFresh)\n        fileTransfers.removeExpired(isFresh: isPacketFresh)\n    }\n\n    private func performPeriodicMaintenance(now: Date = Date()) {\n        cleanupExpiredMessages()\n        cleanupStaleAnnouncementsIfNeeded(now: now)\n        requestSyncManager.cleanup() // Cleanup expired sync requests\n        \n        for index in syncSchedules.indices {\n            guard syncSchedules[index].interval > 0 else { continue }\n            if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {\n                syncSchedules[index].lastSent = now\n                sendPeriodicSync(for: syncSchedules[index].types)\n            }\n        }\n    }\n\n    private func cleanupStaleAnnouncementsIfNeeded(now: Date) {\n        guard now.timeIntervalSince(lastStalePeerCleanup) >= config.stalePeerCleanupIntervalSeconds else {\n            return\n        }\n        lastStalePeerCleanup = now\n        cleanupStaleAnnouncements(now: now)\n    }\n\n    private func cleanupStaleAnnouncements(now: Date) {\n        let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)\n        let nowMs = UInt64(now.timeIntervalSince1970 * 1000)\n        guard nowMs >= timeoutMs else { return }\n        let cutoff = nowMs - timeoutMs\n        let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in\n            pair.packet.timestamp < cutoff ? peerID : nil\n        }\n        guard !stalePeerIDs.isEmpty else { return }\n        for peerKey in stalePeerIDs {\n            removeState(for: peerKey)\n        }\n    }\n\n    // Explicit removal hook for LEAVE/stale peer\n    func removeAnnouncementForPeer(_ peerID: PeerID) {\n        queue.async { [weak self] in\n            self?.removeState(for: peerID)\n        }\n    }\n\n    private func removeState(for peerID: PeerID) {\n        _ = latestAnnouncementByPeer.removeValue(forKey: peerID)\n        messages.remove { PeerID(hexData: $0.senderID) == peerID }\n        fragments.remove { PeerID(hexData: $0.senderID) == peerID }\n        fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }\n    }\n}\n\n#if DEBUG\nextension GossipSyncManager {\n    func _performMaintenanceSynchronously(now: Date = Date()) {\n        queue.sync {\n            performPeriodicMaintenance(now: now)\n        }\n    }\n\n    func _hasAnnouncement(for peerID: PeerID) -> Bool {\n        queue.sync {\n            latestAnnouncementByPeer[peerID] != nil\n        }\n    }\n\n    func _messageCount(for peerID: PeerID) -> Int {\n        queue.sync {\n            messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count\n        }\n    }\n}\n#endif\n"
  },
  {
    "path": "bitchat/Sync/PacketIdUtil.swift",
    "content": "import Foundation\nimport CryptoKit\n\n// Deterministic packet ID used for gossip sync membership\n// ID = first 16 bytes of SHA-256 over: [type | senderID | timestamp | payload]\nenum PacketIdUtil {\n    static func computeId(_ packet: BitchatPacket) -> Data {\n        var hasher = SHA256()\n        hasher.update(data: Data([packet.type]))\n        hasher.update(data: packet.senderID)\n        var tsBE = packet.timestamp.bigEndian\n        withUnsafeBytes(of: &tsBE) { raw in hasher.update(data: Data(raw)) }\n        hasher.update(data: packet.payload)\n        let digest = hasher.finalize()\n        return Data(digest.prefix(16))\n    }\n}\n"
  },
  {
    "path": "bitchat/Sync/RequestSyncManager.swift",
    "content": "//\n// RequestSyncManager.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\nimport BitLogger\n\n/// Manages outgoing sync requests and validates incoming responses.\n///\n/// Allows attributing RSR (Request-Sync Response) packets to specific peers\n/// that we have actively requested sync from.\nfinal class RequestSyncManager {\n    \n    private let queue = DispatchQueue(label: \"request.sync.manager\", attributes: .concurrent)\n    private var pendingRequests: [PeerID: TimeInterval] = [:]\n    private let responseWindow: TimeInterval\n    private let now: () -> TimeInterval\n    \n    init(\n        responseWindow: TimeInterval = 30.0,\n        now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }\n    ) {\n        self.responseWindow = responseWindow\n        self.now = now\n    }\n    \n    /// Register that we are sending a sync request to a peer.\n    /// - Parameter peerID: The peer we are requesting sync from\n    func registerRequest(to peerID: PeerID) {\n        let now = self.now()\n        queue.async(flags: .barrier) {\n            SecureLogger.debug(\"Registering sync request to \\(peerID.id.prefix(8))…\", category: .sync)\n            self.pendingRequests[peerID] = now\n        }\n    }\n    \n    /// Check if a packet from a peer is a valid response to a sync request.\n    ///\n    /// - Parameters:\n    ///   - peerID: The sender of the packet\n    ///   - isRSR: Whether the packet is marked as a Request-Sync Response\n    /// - Returns: true if we have a pending request for this peer and the window is open\n    func isValidResponse(from peerID: PeerID, isRSR: Bool) -> Bool {\n        guard isRSR else { return false }\n        \n        return queue.sync {\n            guard let requestTime = pendingRequests[peerID] else {\n                SecureLogger.warning(\"Received unsolicited RSR packet from \\(peerID.id.prefix(8))…\", category: .security)\n                return false\n            }\n            \n            let now = self.now()\n            if now - requestTime > responseWindow {\n                SecureLogger.warning(\"Received RSR packet from \\(peerID.id.prefix(8))… outside of response window\", category: .security)\n                // We don't remove here because we might receive multiple packets for one request\n                return false\n            }\n            \n            return true\n        }\n    }\n    \n    /// Periodic cleanup of expired requests\n    func cleanup() {\n        let now = self.now()\n        queue.async(flags: .barrier) {\n            let originalCount = self.pendingRequests.count\n            self.pendingRequests = self.pendingRequests.filter { _, timestamp in\n                now - timestamp <= self.responseWindow\n            }\n            let removed = originalCount - self.pendingRequests.count\n            if removed > 0 {\n                SecureLogger.debug(\"Cleaned up \\(removed) expired sync requests\", category: .sync)\n            }\n        }\n    }\n\n    var debugPendingRequestCount: Int {\n        queue.sync { pendingRequests.count }\n    }\n}\n"
  },
  {
    "path": "bitchat/Sync/SyncTypeFlags.swift",
    "content": "import Foundation\n\n/// Bitfield describing which message types are covered by a REQUEST_SYNC round.\n/// Matches the Android mapping (bit index -> message type).\nstruct SyncTypeFlags: OptionSet {\n    let rawValue: UInt64\n\n    init(rawValue: UInt64) {\n        self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes\n    }\n\n    private static func bitIndex(for type: MessageType) -> Int? {\n        switch type {\n        case .announce: return 0\n        case .message: return 1\n        case .leave: return 2\n        case .noiseHandshake: return 3\n        case .noiseEncrypted: return 4\n        case .fragment: return 5\n        case .requestSync: return 6\n        case .fileTransfer: return 7\n        }\n    }\n\n    private static func type(forBit index: Int) -> MessageType? {\n        switch index {\n        case 0: return .announce\n        case 1: return .message\n        case 2: return .leave\n        case 3: return .noiseHandshake\n        case 4: return .noiseEncrypted\n        case 5: return .fragment\n        case 6: return .requestSync\n        case 7: return .fileTransfer\n        default:\n            return nil\n        }\n    }\n\n    static let announce = SyncTypeFlags(messageTypes: [.announce])\n    static let message = SyncTypeFlags(messageTypes: [.message])\n    static let fragment = SyncTypeFlags(messageTypes: [.fragment])\n    static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])\n\n    static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])\n\n    init(messageTypes: [MessageType]) {\n        var raw: UInt64 = 0\n        for type in messageTypes {\n            guard let bit = SyncTypeFlags.bitIndex(for: type) else { continue }\n            raw |= (1 << UInt64(bit))\n        }\n        self.init(rawValue: raw)\n    }\n\n    func contains(_ type: MessageType) -> Bool {\n        guard let bit = SyncTypeFlags.bitIndex(for: type) else { return false }\n        return contains(SyncTypeFlags(rawValue: 1 << UInt64(bit)))\n    }\n\n    func union(_ other: SyncTypeFlags) -> SyncTypeFlags {\n        SyncTypeFlags(rawValue: rawValue | other.rawValue)\n    }\n\n    func intersection(_ other: SyncTypeFlags) -> SyncTypeFlags {\n        SyncTypeFlags(rawValue: rawValue & other.rawValue)\n    }\n\n    func toMessageTypes() -> [MessageType] {\n        guard rawValue != 0 else { return [] }\n        var types: [MessageType] = []\n        for bit in 0..<64 {\n            guard (rawValue & (1 << UInt64(bit))) != 0 else { continue }\n            if let type = SyncTypeFlags.type(forBit: bit) {\n                types.append(type)\n            }\n        }\n        return types\n    }\n\n    func toData() -> Data? {\n        guard rawValue != 0 else { return nil }\n        var value = rawValue\n        var bytes: [UInt8] = []\n        while value > 0 && bytes.count < 8 {\n            bytes.append(UInt8(value & 0xFF))\n            value >>= 8\n        }\n        while let last = bytes.last, last == 0 {\n            bytes.removeLast()\n        }\n        guard !bytes.isEmpty, bytes.count <= 8 else { return nil }\n        return Data(bytes)\n    }\n\n    static func decode(_ data: Data) -> SyncTypeFlags? {\n        guard (1...8).contains(data.count) else { return nil }\n        var raw: UInt64 = 0\n        for (index, byte) in data.enumerated() {\n            raw |= UInt64(byte) << UInt64(index * 8)\n        }\n        return SyncTypeFlags(rawValue: raw)\n    }\n}\n"
  },
  {
    "path": "bitchat/Utils/Color+Peer.swift",
    "content": "//\n// Color+Peer.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport SwiftUI\n\nextension Color {\n    private static var peerColorCache: [String: Color] = [:]\n    \n    init(peerSeed: String, isDark: Bool) {\n        let cacheKey = peerSeed + (isDark ? \"|dark\" : \"|light\")\n        if let cached = Self.peerColorCache[cacheKey] {\n            self = cached\n        }\n        let h = peerSeed.djb2()\n        var hue = Double(h % 1000) / 1000.0\n        let orange = 30.0 / 360.0\n        if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta {\n            hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0)\n        }\n        let sRand = Double((h >> 17) & 0x3FF) / 1023.0\n        let bRand = Double((h >> 27) & 0x3FF) / 1023.0\n        let sBase: Double = isDark ? 0.80 : 0.70\n        let sRange: Double = 0.20\n        let bBase: Double = isDark ? 0.75 : 0.45\n        let bRange: Double = isDark ? 0.16 : 0.14\n        let saturation = min(1.0, max(0.50, sBase + (sRand - 0.5) * sRange))\n        let brightness = min(1.0, max(0.35, bBase + (bRand - 0.5) * bRange))\n        let c = Color(hue: hue, saturation: saturation, brightness: brightness)\n        Self.peerColorCache[cacheKey] = c\n        self = c\n    }\n}\n"
  },
  {
    "path": "bitchat/Utils/CompressionUtil.swift",
    "content": "//\n// CompressionUtil.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\nimport Compression\n\nstruct CompressionUtil {\n    // Compression threshold - don't compress if data is smaller than this\n    static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes\n    \n    // Compress data using zlib algorithm (most compatible)\n    static func compress(_ data: Data) -> Data? {\n        // Skip compression for small data\n        guard data.count >= compressionThreshold else { return nil }\n        \n        let maxCompressedSize = data.count + (data.count / 255) + 16\n        let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: maxCompressedSize)\n        defer { destinationBuffer.deallocate() }\n        \n        let compressedSize = data.withUnsafeBytes { sourceBuffer in\n            guard let sourcePtr = sourceBuffer.bindMemory(to: UInt8.self).baseAddress else { return 0 }\n            return compression_encode_buffer(\n                destinationBuffer, data.count,\n                sourcePtr, data.count,\n                nil, COMPRESSION_ZLIB\n            )\n        }\n        \n        guard compressedSize > 0 && compressedSize < data.count else { return nil }\n        \n        return Data(bytes: destinationBuffer, count: compressedSize)\n    }\n    \n    // Decompress zlib compressed data\n    static func decompress(_ compressedData: Data, originalSize: Int) -> Data? {\n        let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: originalSize)\n        defer { destinationBuffer.deallocate() }\n        \n        let decompressedSize = compressedData.withUnsafeBytes { sourceBuffer in\n            guard let sourcePtr = sourceBuffer.bindMemory(to: UInt8.self).baseAddress else { return 0 }\n            return compression_decode_buffer(\n                destinationBuffer, originalSize,\n                sourcePtr, compressedData.count,\n                nil, COMPRESSION_ZLIB\n            )\n        }\n        \n        guard decompressedSize > 0 else { return nil }\n        \n        return Data(bytes: destinationBuffer, count: decompressedSize)\n    }\n    \n    // Helper to check if compression is worth it\n    static func shouldCompress(_ data: Data) -> Bool {\n        // Don't compress if:\n        // 1. Data is too small\n        // 2. Data appears to be already compressed (high entropy)\n        guard data.count >= compressionThreshold else { return false }\n\n        // Quick uniqueness check — a high diversity of bytes usually means the\n        // payload is already compressed. We only need to know how many unique\n        // values exist rather than keeping full frequency counts.\n        let uniqueByteCount = Set(data).count\n        let sampleSize = min(data.count, 256)\n        let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)\n        return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes\n    }\n}\n"
  },
  {
    "path": "bitchat/Utils/Data+SHA256.swift",
    "content": "//\n//  Data+SHA256.swift\n//  bitchat\n//\n//  Created by Islam on 26/09/2025.\n//\n\nimport struct Foundation.Data\nimport struct CryptoKit.SHA256\n\nextension Data {\n    /// Returns the hex representation of SHA256 hash\n    func sha256Fingerprint() -> String {\n        // Implementation matches existing fingerprint generation in NoiseEncryptionService\n        sha256Hash().hexEncodedString()\n    }\n    \n    /// Returns the SHA256 hash wrapped in Data\n    func sha256Hash() -> Data {\n        Data(SHA256.hash(data: self))\n    }\n}\n"
  },
  {
    "path": "bitchat/Utils/FileTransferLimits.swift",
    "content": "import Foundation\n\n/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.\nenum FileTransferLimits {\n    /// Absolute ceiling enforced for any file payload (voice, image, other).\n    static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB\n    /// Voice notes stay small for low-latency relays.\n    static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB\n    /// Compressed images after downscaling should comfortably fit under this budget.\n    static let maxImageBytes: Int = 512 * 1024 // 512 KiB\n    /// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.\n    static let maxFramedFileBytes: Int = {\n        let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs\n        let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes\n        let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize\n            + BinaryProtocol.senderIDSize\n            + BinaryProtocol.recipientIDSize\n            + BinaryProtocol.signatureSize\n        return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead\n    }()\n\n    static func isValidPayload(_ size: Int) -> Bool {\n        size <= maxPayloadBytes\n    }\n}\n"
  },
  {
    "path": "bitchat/Utils/Font+Bitchat.swift",
    "content": "import SwiftUI\n\n/// Provides Dynamic Type aware font helpers that map existing fixed sizes onto\n/// preferred text styles so the UI scales with user accessibility settings.\nextension Font {\n    static func bitchatSystem(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> Font {\n        let style = Font.TextStyle.bitchatPreferredStyle(for: size)\n        var font = Font.system(style, design: design)\n        if weight != .regular {\n            font = font.weight(weight)\n        }\n        return font\n    }\n}\n\nprivate extension Font.TextStyle {\n    static func bitchatPreferredStyle(for size: CGFloat) -> Font.TextStyle {\n        switch size {\n        case ..<11.5:\n            return .caption2\n        case ..<13.0:\n            return .caption\n        case ..<13.75:\n            return .footnote\n        case ..<15.5:\n            return .subheadline\n        case ..<17.5:\n            return .callout\n        case ..<19.5:\n            return .body\n        case ..<22.5:\n            return .title3\n        case ..<27.5:\n            return .title2\n        case ..<34.0:\n            return .title\n        default:\n            return .largeTitle\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Utils/InputValidator.swift",
    "content": "import Foundation\nimport BitLogger\n\n/// Comprehensive input validation for BitChat protocol\n/// Prevents injection attacks, buffer overflows, and malformed data\nstruct InputValidator {\n    \n    // MARK: - Constants\n    \n    struct Limits {\n        static let maxNicknameLength = 50\n        // BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom\n        // for headers/padding by limiting user content to 60_000 bytes.\n        static let maxMessageLength = 60_000\n    }\n    \n    // MARK: - String Content Validation\n    \n    /// Validates and sanitizes user-provided strings used in UI\n    ///\n    /// Rejects strings containing control characters to prevent potential security issues\n    /// and UI rendering problems. This strict approach ensures data integrity at input time.\n    static func validateUserString(_ string: String, maxLength: Int) -> String? {\n        let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty else { return nil }\n        guard trimmed.count <= maxLength else { return nil }\n\n        // Reject control characters outright instead of rewriting the string.\n        // This prevents injection attacks and ensures consistent UI rendering.\n        let controlChars = CharacterSet.controlCharacters\n        if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {\n            // Log rejection for monitoring, without exposing actual content for privacy\n            let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count\n            SecureLogger.debug(\n                \"Input validation rejected string (length: \\(trimmed.count), control chars: \\(controlCharCount))\",\n                category: .security\n            )\n            return nil\n        }\n\n        return trimmed\n    }\n    \n    /// Validates nickname\n    static func validateNickname(_ nickname: String) -> String? {\n        return validateUserString(nickname, maxLength: Limits.maxNicknameLength)\n    }\n    \n    // MARK: - Protocol Field Validation\n\n    // Note: Message type validation is performed closer to decoding using\n    // MessageType/NoisePayloadType enums; keeping validator free of stale lists.\n\n    /// Validates timestamp is reasonable (not too far in past or future)\n    /// BCH-01-011: Reduced from ±1 hour to ±5 minutes to limit replay attack window\n    static func validateTimestamp(_ timestamp: Date) -> Bool {\n        let now = Date()\n        // 5 minutes = 300 seconds (industry standard for replay protection)\n        let fiveMinutesAgo = now.addingTimeInterval(-300)\n        let fiveMinutesFromNow = now.addingTimeInterval(300)\n        return timestamp >= fiveMinutesAgo && timestamp <= fiveMinutesFromNow\n    }\n\n}\n"
  },
  {
    "path": "bitchat/Utils/MessageDeduplicator.swift",
    "content": "import Foundation\n\n// MARK: - Message Deduplicator (shared)\n\n/// Thread-safe deduplicator with LRU eviction and time-based expiry.\n/// Used for both message ID deduplication (network layer) and content key deduplication (UI layer).\nfinal class MessageDeduplicator {\n    private struct Entry: Equatable {\n        let id: String\n        let timestamp: Date\n    }\n\n    private var entries: [Entry] = []\n    private var head: Int = 0\n    private var lookup: [String: Date] = [:]  // id -> timestamp for O(1) lookup\n    private let lock = NSLock()\n    private let maxAge: TimeInterval\n    private let maxCount: Int\n\n    /// Initialize with default config from TransportConfig\n    convenience init() {\n        self.init(\n            maxAge: TransportConfig.messageDedupMaxAgeSeconds,\n            maxCount: TransportConfig.messageDedupMaxCount\n        )\n    }\n\n    /// Initialize with custom config for content deduplication\n    init(maxAge: TimeInterval, maxCount: Int) {\n        self.maxAge = maxAge\n        self.maxCount = maxCount\n    }\n\n    /// Check if message is duplicate and add if not.\n    /// - Parameter id: The message identifier to check.\n    /// - Returns: `true` if the message was already seen, `false` otherwise.\n    func isDuplicate(_ id: String) -> Bool {\n        lock.lock()\n        defer { lock.unlock() }\n\n        let now = Date()\n        cleanupOldEntries(before: now.addingTimeInterval(-maxAge))\n\n        if lookup[id] != nil {\n            return true\n        }\n\n        entries.append(Entry(id: id, timestamp: now))\n        lookup[id] = now\n        trimIfNeeded()\n\n        return false\n    }\n\n    /// Record an ID with a specific timestamp (for content key tracking)\n    func record(_ id: String, timestamp: Date) {\n        lock.lock()\n        defer { lock.unlock() }\n\n        if lookup[id] == nil {\n            entries.append(Entry(id: id, timestamp: timestamp))\n        }\n        lookup[id] = timestamp\n        trimIfNeeded()\n    }\n\n    /// Add an ID without checking (for announce-back tracking)\n    func markProcessed(_ id: String) {\n        lock.lock()\n        defer { lock.unlock() }\n\n        if lookup[id] == nil {\n            let now = Date()\n            entries.append(Entry(id: id, timestamp: now))\n            lookup[id] = now\n        }\n    }\n\n    /// Check if ID exists without adding\n    func contains(_ id: String) -> Bool {\n        lock.lock()\n        defer { lock.unlock() }\n        return lookup[id] != nil\n    }\n\n    /// Get timestamp for an ID (for content deduplication time-window checks)\n    func timestampFor(_ id: String) -> Date? {\n        lock.lock()\n        defer { lock.unlock() }\n        return lookup[id]\n    }\n\n    private func trimIfNeeded() {\n        let activeCount = entries.count - head\n        guard activeCount > maxCount else { return }\n\n        // Remove down to 75% of maxCount for better amortization\n        let targetCount = (maxCount * 3) / 4\n        let removeCount = activeCount - targetCount\n\n        for i in head..<(head + removeCount) {\n            lookup.removeValue(forKey: entries[i].id)\n        }\n        head += removeCount\n\n        // Compact when head exceeds half the array to reclaim memory\n        if head > entries.count / 2 {\n            entries.removeFirst(head)\n            head = 0\n        }\n    }\n\n    /// Clear all entries\n    func reset() {\n        lock.lock()\n        defer { lock.unlock() }\n\n        entries.removeAll()\n        head = 0\n        lookup.removeAll()\n    }\n\n    /// Periodic cleanup of expired entries and memory optimization.\n    func cleanup() {\n        lock.lock()\n        defer { lock.unlock() }\n\n        cleanupOldEntries(before: Date().addingTimeInterval(-maxAge))\n\n        // Shrink capacity if significantly oversized\n        if entries.capacity > maxCount * 2 && entries.count < maxCount {\n            entries.reserveCapacity(maxCount)\n        }\n    }\n\n    private func cleanupOldEntries(before cutoff: Date) {\n        while head < entries.count, entries[head].timestamp < cutoff {\n            lookup.removeValue(forKey: entries[head].id)\n            head += 1\n        }\n        // Compact when head exceeds half the array\n        if head > 0 && head > entries.count / 2 {\n            entries.removeFirst(head)\n            head = 0\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Utils/PeerDisplayNameResolver.swift",
    "content": "import Foundation\n\n/// Resolves a stable display name for peers, adding a short suffix when collisions exist.\nstruct PeerDisplayNameResolver {\n    /// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.\n    /// - Parameters:\n    ///   - peers: Array of tuples (peerID, nickname, isConnected).\n    ///   - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.\n    /// - Returns: Map of peerID -> displayName.\n    static func resolve(_ peers: [(peerID: PeerID, nickname: String, isConnected: Bool)], selfNickname: String) -> [PeerID: String] {\n        // Count collisions among connected peers and include our own nickname\n        var counts: [String: Int] = [:]\n        for p in peers where p.isConnected {\n            counts[p.nickname, default: 0] += 1\n        }\n        counts[selfNickname, default: 0] += 1\n\n        var result: [PeerID: String] = [:]\n        for p in peers {\n            var name = p.nickname\n            if p.isConnected, (counts[p.nickname] ?? 0) > 1 {\n                name += \"#\" + String(p.peerID.id.prefix(4))\n            }\n            result[p.peerID] = name\n        }\n        return result\n    }\n}\n\n"
  },
  {
    "path": "bitchat/Utils/String+DJB2.swift",
    "content": "//\n// String+DJB2.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\nextension String {\n    func djb2() -> UInt64 {\n        var hash: UInt64 = 5381\n        for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) }\n        return hash\n    }\n}\n"
  },
  {
    "path": "bitchat/Utils/String+Nickname.swift",
    "content": "//\n// String+Nickname.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\nextension String {\n    /// Split a nickname into base and a '#abcd' suffix if present\n    func splitSuffix() -> (String, String) {\n        let name = self.replacingOccurrences(of: \"@\", with: \"\")\n        guard name.count >= 5 else { return (name, \"\") }\n        let suffix = String(name.suffix(5))\n        if suffix.first == \"#\", suffix.dropFirst().allSatisfy({ c in\n            (\"0\"...\"9\").contains(String(c)) || (\"a\"...\"f\").contains(String(c)) || (\"A\"...\"F\").contains(String(c))\n        }) {\n            let base = String(name.dropLast(5))\n            return (base, suffix)\n        }\n        return (name, \"\")\n    }\n}\n"
  },
  {
    "path": "bitchat/ViewModels/ChatViewModel.swift",
    "content": "//\n// ChatViewModel.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\n///\n/// # ChatViewModel\n///\n/// The central business logic and state management component for BitChat.\n/// Coordinates between the UI layer and the networking/encryption services.\n///\n/// ## Overview\n/// ChatViewModel implements the MVVM pattern, serving as the binding layer between\n/// SwiftUI views and the underlying BitChat services. It manages:\n/// - Message state and delivery\n/// - Peer connections and presence\n/// - Private chat sessions\n/// - Command processing\n/// - UI state like autocomplete and notifications\n///\n/// ## Architecture\n/// The ViewModel acts as:\n/// - **BitchatDelegate**: Receives messages and events from BLEService\n/// - **State Manager**: Maintains all UI-relevant state with @Published properties\n/// - **Command Processor**: Handles IRC-style commands (/msg, /who, etc.)\n/// - **Message Router**: Directs messages to appropriate chats (public/private)\n///\n/// ## Key Features\n///\n/// ### Message Management\n/// - Efficient message handling with duplicate detection\n/// - Maintains separate public and private message queues\n/// - Limits message history to prevent memory issues (1337 messages)\n/// - Tracks delivery and read receipts\n///\n/// ### Privacy Features\n/// - Ephemeral by design - no persistent message storage\n/// - Supports verified fingerprints for secure communication\n/// - Blocks messages from blocked users\n/// - Emergency wipe capability (triple-tap)\n///\n/// ### User Experience\n/// - Smart autocomplete for mentions and commands\n/// - Unread message indicators\n/// - Connection status tracking\n/// - Favorite peers management\n///\n/// ## Command System\n/// Supports IRC-style commands:\n/// - `/nick <name>`: Change nickname\n/// - `/msg <user> <message>`: Send private message\n/// - `/who`: List connected peers\n/// - `/slap <user>`: Fun interaction\n/// - `/clear`: Clear message history\n/// - `/help`: Show available commands\n///\n/// ## Performance Optimizations\n/// - SwiftUI automatically optimizes UI updates\n/// - Caches expensive computations (encryption status)\n/// - Debounces autocomplete suggestions\n/// - Efficient peer list management\n///\n/// ## Thread Safety\n/// - All @Published properties trigger UI updates on main thread\n/// - Background operations use proper queue management\n/// - Atomic operations for critical state updates\n///\n/// ## Usage Example\n/// ```swift\n/// let viewModel = ChatViewModel()\n/// viewModel.nickname = \"Alice\"\n/// viewModel.startServices()\n/// viewModel.sendMessage(\"Hello, mesh network!\")\n/// ```\n///\n\nimport BitLogger\nimport Foundation\nimport SwiftUI\nimport Combine\nimport CommonCrypto\nimport CoreBluetooth\nimport Tor\n#if os(iOS)\nimport UIKit\n#endif\nimport UniformTypeIdentifiers\n\n/// Manages the application state and business logic for BitChat.\n/// Acts as the primary coordinator between UI components and backend services,\n/// implementing the BitchatDelegate protocol to handle network events.\nfinal class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {\n    // Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)\n    typealias Patterns = MessageFormattingEngine.Patterns\n\n    typealias GeoOutgoingContext = (channel: GeohashChannel, event: NostrEvent, identity: NostrIdentity, teleported: Bool)\n\n    @MainActor\n    var canSendMediaInCurrentContext: Bool {\n        if let peer = selectedPrivateChatPeer {\n            return !(peer.isGeoDM || peer.isGeoChat)\n        }\n        switch activeChannel {\n        case .mesh: return true\n        case .location: return false\n        }\n    }\n\n    private var publicRateLimiter = MessageRateLimiter(\n        senderCapacity: TransportConfig.uiSenderRateBucketCapacity,\n        senderRefillPerSec: TransportConfig.uiSenderRateBucketRefillPerSec,\n        contentCapacity: TransportConfig.uiContentRateBucketCapacity,\n        contentRefillPerSec: TransportConfig.uiContentRateBucketRefillPerSec\n    )\n\n    @MainActor\n    private func normalizedSenderKey(for message: BitchatMessage) -> String {\n        if let spid = message.senderPeerID {\n            if spid.isGeoChat || spid.isGeoDM {\n                let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased()\n                return \"nostr:\" + full\n            } else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.id.lowercased() {\n                return \"noise:\" + full\n            } else {\n                return \"mesh:\" + spid.id.lowercased()\n            }\n        }\n        return \"name:\" + message.sender.lowercased()\n    }\n\n    // MARK: - Published Properties\n    \n    @Published var messages: [BitchatMessage] = []\n    @Published var currentColorScheme: ColorScheme = .light\n    private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed\n    @Published var isConnected = false\n    private var recentlySeenPeers: Set<PeerID> = []\n    private var lastNetworkNotificationTime = Date.distantPast\n    private var networkResetTimer: Timer? = nil\n    private var networkEmptyTimer: Timer? = nil\n    private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects\n    @Published var nickname: String = \"\" {\n        didSet {\n            // Trim whitespace whenever nickname is set\n            let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)\n            if trimmed != nickname {\n                nickname = trimmed\n            }\n            // Update mesh service nickname if it's initialized\n            if !meshService.myPeerID.isEmpty {\n                meshService.setNickname(nickname)\n            }\n        }\n    }\n    \n    // MARK: - Service Delegates\n\n    let commandProcessor: CommandProcessor\n    let messageRouter: MessageRouter\n    let privateChatManager: PrivateChatManager\n    let unifiedPeerService: UnifiedPeerService\n    let autocompleteService: AutocompleteService\n    let deduplicationService: MessageDeduplicationService  // internal for test access\n    \n    // Computed properties for compatibility\n    @MainActor\n    var connectedPeers: Set<PeerID> { unifiedPeerService.connectedPeerIDs }\n    @Published var allPeers: [BitchatPeer] = []\n    var privateChats: [PeerID: [BitchatMessage]] {\n        get { privateChatManager.privateChats }\n        set { privateChatManager.privateChats = newValue }\n    }\n    var selectedPrivateChatPeer: PeerID? {\n        get { privateChatManager.selectedPeer }\n        set { \n            if let peerID = newValue {\n                privateChatManager.startChat(with: peerID)\n            } else {\n                privateChatManager.endChat()\n            }\n        }\n    }\n    var unreadPrivateMessages: Set<PeerID> {\n        get { privateChatManager.unreadMessages }\n        set { privateChatManager.unreadMessages = newValue }\n    }\n    \n    /// Check if there are any unread messages (including from temporary Nostr peer IDs)\n    var hasAnyUnreadMessages: Bool {\n        !unreadPrivateMessages.isEmpty\n    }\n\n    /// Open the most relevant private chat when tapping the toolbar unread icon.\n    /// Prefers the most recently active unread conversation, otherwise the most recent PM.\n    @MainActor\n    func openMostRelevantPrivateChat() {\n        // Pick most recent unread by last message timestamp\n        let unreadSorted = unreadPrivateMessages\n            .map { ($0, privateChats[$0]?.last?.timestamp ?? Date.distantPast) }\n            .sorted { $0.1 > $1.1 }\n        if let target = unreadSorted.first?.0 {\n            startPrivateChat(with: target)\n            return\n        }\n        // Otherwise pick most recent private chat overall\n        let recent = privateChats\n            .map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) }\n            .sorted { $0.ts > $1.ts }\n        if let target = recent.first?.id {\n            startPrivateChat(with: target)\n        }\n    }\n    \n    //\n    var peerIDToPublicKeyFingerprint: [PeerID: String] = [:]\n    private var selectedPrivateChatFingerprint: String? = nil\n    // Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity\n    private var shortIDToNoiseKey: [PeerID: PeerID] = [:]\n\n    // Resolve full Noise key for a peer's short ID (used by UI header rendering)\n    @MainActor\n    private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> PeerID? {\n        if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }\n        // Fallback: derive from active Noise session if available\n        if shortPeerID.id.count == 16,\n           let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {\n            let stable = PeerID(hexData: key)\n            shortIDToNoiseKey[shortPeerID] = stable\n            return stable\n        }\n        return nil\n    }\n\n    // Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex)\n    @MainActor\n    func getShortIDForNoiseKey(_ fullNoiseKeyHex: PeerID) -> PeerID {\n        guard fullNoiseKeyHex.id.count == 64 else { return fullNoiseKeyHex }\n        // Check known peers for a noise key match\n        if let match = allPeers.first(where: { PeerID(hexData: $0.noisePublicKey) == fullNoiseKeyHex }) {\n            return match.peerID\n        }\n        // Also search cache mapping\n        if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) {\n            return pair.key\n        }\n        return fullNoiseKeyHex\n    }\n    private var peerIndex: [PeerID: BitchatPeer] = [:]\n    \n    // MARK: - Autocomplete Properties\n    \n    @Published var autocompleteSuggestions: [String] = []\n    @Published var showAutocomplete: Bool = false\n    @Published var autocompleteRange: NSRange? = nil\n    @Published var selectedAutocompleteIndex: Int = 0\n    \n    // Temporary property to fix compilation\n    @Published var showPasswordPrompt = false\n    \n    // MARK: - Services and Storage\n    \n    let meshService: Transport\n    let idBridge: NostrIdentityBridge\n    let identityManager: SecureIdentityStateManagerProtocol\n    \n    var nostrRelayManager: NostrRelayManager?\n    private let userDefaults = UserDefaults.standard\n    let keychain: KeychainManagerProtocol\n    private let nicknameKey = \"bitchat.nickname\"\n    // Location channel state (macOS supports manual geohash selection)\n    @Published var activeChannel: ChannelID = .mesh\n    var geoSubscriptionID: String? = nil\n    var geoDmSubscriptionID: String? = nil\n    var currentGeohash: String? = nil\n    var cachedGeohashIdentity: (geohash: String, identity: NostrIdentity)? = nil // Cache current geohash identity\n    var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname\n    // Show Tor status once per app launch\n    var torStatusAnnounced = false\n    // Track whether a Tor restart is pending so we only announce\n    // \"tor restarted\" after an actual restart, not the first launch.\n    var torRestartPending: Bool = false\n    // Ensure we set up DM subscription only once per app session\n    var nostrHandlersSetup: Bool = false\n    var geoChannelCoordinator: GeoChannelCoordinator?\n    \n    // MARK: - Caches\n    \n    // Caches for expensive computations\n    private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:]\n    \n    // MARK: - Social Features (Delegated to PeerStateManager)\n    \n    @MainActor\n    var favoritePeers: Set<String> { unifiedPeerService.favoritePeers }\n    @MainActor\n    var blockedUsers: Set<String> { unifiedPeerService.blockedUsers }\n    \n    // MARK: - Encryption and Security\n    \n    // Noise Protocol encryption status\n    @Published var peerEncryptionStatus: [PeerID: EncryptionStatus] = [:]\n    @Published var verifiedFingerprints: Set<String> = []  // Set of verified fingerprints\n    @Published var showingFingerprintFor: PeerID? = nil  // Currently showing fingerprint sheet for peer\n    \n    // Bluetooth state management\n    @Published var showBluetoothAlert = false\n    @Published var bluetoothAlertMessage = \"\"\n    @Published var bluetoothState: CBManagerState = .unknown\n\n    // Presentation state for privacy gating\n    @Published var isLocationChannelsSheetPresented: Bool = false\n    @Published var isAppInfoPresented: Bool = false\n    @Published var showScreenshotPrivacyWarning: Bool = false\n    \n    var timelineStore = PublicTimelineStore(\n        meshCap: TransportConfig.meshTimelineCap,\n        geohashCap: TransportConfig.geoTimelineCap\n    )\n    // Channel activity tracking for background nudges\n    var lastPublicActivityAt: [String: Date] = [:]   // channelKey -> last activity time\n    // Geohash participant tracker\n    let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)\n    // Participants who indicated they teleported (by tag in their events)\n    @Published var teleportedGeo: Set<String> = []  // lowercased pubkey hex\n    // Sampling subscriptions for multiple geohashes (when channel sheet is open)\n    var geoSamplingSubs: [String: String] = [:] // subID -> geohash\n    var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time\n    \n    \n    // MARK: - Message Delivery Tracking\n    \n    // Delivery tracking\n    var cancellables = Set<AnyCancellable>()\n    var transferIdToMessageIDs: [String: [String]] = [:]\n    var messageIDToTransferId: [String: String] = [:]\n\n    // MARK: - QR Verification (pending state)\n    private struct PendingVerification {\n        let noiseKeyHex: String\n        let signKeyHex: String\n        let nonceA: Data\n        let startedAt: Date\n        var sent: Bool\n    }\n    private var pendingQRVerifications: [PeerID: PendingVerification] = [:]\n    // Last handled challenge nonce per peer to avoid duplicate responses\n    private var lastVerifyNonceByPeer: [PeerID: Data] = [:]\n    // Track when we last received a verify challenge from a peer (fingerprint-keyed)\n    private var lastInboundVerifyChallengeAt: [String: Date] = [:] // key: fingerprint\n    // Throttle mutual verification toasts per fingerprint\n    private var lastMutualToastAt: [String: Date] = [:] // key: fingerprint\n\n    // MARK: - Public message batching (UI perf)\n    let publicMessagePipeline: PublicMessagePipeline\n    @Published private(set) var isBatchingPublic: Bool = false\n    \n    // Track sent read receipts to avoid duplicates (persisted across launches)\n    // Note: Persistence happens automatically in didSet, no lifecycle observers needed\n    var sentReadReceipts: Set<String> = [] {  // messageID set\n        didSet {\n            // Only persist if there are changes\n            guard oldValue != sentReadReceipts else { return }\n            \n            // Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read)\n            if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {\n                UserDefaults.standard.set(data, forKey: \"sentReadReceipts\")\n            } else {\n                SecureLogger.error(\"❌ Failed to encode read receipts for persistence\", category: .session)\n            }\n        }\n    }\n\n    // Throttle verification response toasts per peer to avoid spam\n    var lastVerifyToastAt: [String: Date] = [:]\n\n    // Track which GeoDM messages we've already sent a delivery ACK for (by messageID)\n    var sentGeoDeliveryAcks: Set<String> = []\n    \n    // Track app startup phase to prevent marking old messages as unread\n    private var isStartupPhase = true\n    // Announce Tor initial readiness once per launch to avoid duplicates\n    var torInitialReadyAnnounced: Bool = false\n    \n    // Track Nostr pubkey mappings for unknown senders\n    var nostrKeyMapping: [PeerID: String] = [:]  // senderPeerID -> nostrPubkey\n    \n    // MARK: - Initialization\n\n    @MainActor\n    convenience init(\n        keychain: KeychainManagerProtocol,\n        idBridge: NostrIdentityBridge,\n        identityManager: SecureIdentityStateManagerProtocol\n    ) {\n        self.init(\n            keychain: keychain,\n            idBridge: idBridge,\n            identityManager: identityManager,\n            transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)\n        )\n    }\n\n    /// Testable initializer that accepts a Transport dependency.\n    /// Use this initializer for unit testing with MockTransport.\n    @MainActor\n    init(\n        keychain: KeychainManagerProtocol,\n        idBridge: NostrIdentityBridge,\n        identityManager: SecureIdentityStateManagerProtocol,\n        transport: Transport\n    ) {\n        self.keychain = keychain\n        self.idBridge = idBridge\n        self.identityManager = identityManager\n        self.meshService = transport\n        self.publicMessagePipeline = PublicMessagePipeline()\n        \n        // Load persisted read receipts\n        if let data = UserDefaults.standard.data(forKey: \"sentReadReceipts\"),\n           let receipts = try? JSONDecoder().decode([String].self, from: data) {\n            self.sentReadReceipts = Set(receipts)\n            // Successfully loaded read receipts\n        } else {\n            // No persisted read receipts found\n        }\n        \n        // Initialize services\n        self.commandProcessor = CommandProcessor(identityManager: identityManager)\n        self.privateChatManager = PrivateChatManager(meshService: meshService)\n        self.unifiedPeerService = UnifiedPeerService(meshService: meshService, idBridge: idBridge, identityManager: identityManager)\n        let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)\n        nostrTransport.senderPeerID = meshService.myPeerID\n        self.messageRouter = MessageRouter(transports: [meshService, nostrTransport])\n        // Route receipts from PrivateChatManager through MessageRouter\n        self.privateChatManager.messageRouter = self.messageRouter\n        // Allow PrivateChatManager to look up peer info for message consolidation\n        self.privateChatManager.unifiedPeerService = self.unifiedPeerService\n        // Allow UnifiedPeerService to route favorite notifications via mesh/Nostr\n        self.unifiedPeerService.messageRouter = self.messageRouter\n        self.autocompleteService = AutocompleteService()\n        self.deduplicationService = MessageDeduplicationService()\n\n        // Wire up dependencies\n        self.commandProcessor.contextProvider = self\n        self.participantTracker.configure(context: self)\n        \n        // Subscribe to privateChatManager changes to trigger UI updates\n        privateChatManager.objectWillChange\n            .sink { [weak self] _ in\n                self?.objectWillChange.send()\n            }\n            .store(in: &cancellables)\n\n        // Subscribe to participantTracker changes to trigger UI updates\n        participantTracker.objectWillChange\n            .sink { [weak self] _ in\n                self?.objectWillChange.send()\n            }\n            .store(in: &cancellables)\n        self.commandProcessor.meshService = meshService\n        \n        loadNickname()\n        loadVerifiedFingerprints()\n        meshService.delegate = self\n        \n        // Log startup info\n        \n        // Log fingerprint after a delay to ensure encryption service is ready\n        DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak self] in\n            if let self = self {\n                _ = self.getMyFingerprint()\n            }\n        }\n        \n        // Set nickname before starting services\n        meshService.setNickname(nickname)\n        \n        // Start mesh service immediately\n        meshService.startServices()\n\n        publicMessagePipeline.delegate = self\n        publicMessagePipeline.updateActiveChannel(activeChannel)\n\n        // Check initial Bluetooth state after a brief delay to allow centralManager initialization\n        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in\n            guard let self = self else { return }\n            if let bleService = self.meshService as? BLEService {\n                let state = bleService.getCurrentBluetoothState()\n                self.updateBluetoothState(state)\n            }\n        }\n\n        // Announce Tor status (geohash-only; do not show in mesh chat). Only when auto-start is allowed.\n        if TorManager.shared.torEnforced && !torStatusAnnounced && TorManager.shared.isAutoStartAllowed() {\n            torStatusAnnounced = true\n            addGeohashOnlySystemMessage(\n                String(localized: \"system.tor.starting\", comment: \"System message when Tor is starting\")\n            )\n        } else if !TorManager.shared.torEnforced && !torStatusAnnounced {\n            torStatusAnnounced = true\n            addGeohashOnlySystemMessage(\n                String(localized: \"system.tor.dev_bypass\", comment: \"System message when Tor bypass is enabled in development\")\n            )\n        }\n\n        // Initialize Nostr relay manager regardless of Tor readiness; connection is controlled elsewhere\n        nostrRelayManager = NostrRelayManager.shared\n        // Attempt to flush any queued outbox (mesh/Nostr routing will gate appropriately)\n        messageRouter.flushAllOutbox()\n        // End startup phase after a short delay\n        Task { @MainActor in\n            try? await Task.sleep(nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000))\n            self.isStartupPhase = false\n        }\n        // Bind unified peer service's peer list to our published property\n        let peersCancellable = unifiedPeerService.$peers\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] peers in\n                guard let self = self else { return }\n                // Update peers directly; @Published drives UI updates\n                self.allPeers = peers\n                // Update peer index for O(1) lookups\n                // Deduplicate peers by ID to prevent crash from duplicate keys\n                var uniquePeers: [PeerID: BitchatPeer] = [:]\n                for peer in peers {\n                    // Keep the first occurrence of each peer ID\n                    if uniquePeers[peer.peerID] == nil {\n                        uniquePeers[peer.peerID] = peer\n                    } else {\n                        SecureLogger.warning(\"⚠️ Duplicate peer ID detected: \\(peer.peerID) (\\(peer.displayName))\", category: .session)\n                    }\n                }\n                self.peerIndex = uniquePeers\n                // Update private chat peer ID if needed when peers change\n                if self.selectedPrivateChatFingerprint != nil {\n                    self.updatePrivateChatPeerIfNeeded()\n                }\n            }\n        self.cancellables.insert(peersCancellable)\n\n        // Resubscribe geohash on relay reconnect\n        if let relayMgr = self.nostrRelayManager {\n            relayMgr.$isConnected\n                .receive(on: DispatchQueue.main)\n                .sink { [weak self] connected in\n                    guard let self = self else { return }\n                    if connected {\n                        Task { @MainActor in\n                            // Set up DM handler once on first connect\n                            if !self.nostrHandlersSetup {\n                                self.setupNostrMessageHandling()\n                                self.nostrHandlersSetup = true\n                            }\n                            self.resubscribeCurrentGeohash()\n                            // Re-init sampling for regional + bookmarked geohashes after reconnect\n                            self.geoChannelCoordinator?.refreshSampling()\n                        }\n                    }\n                }\n                .store(in: &self.cancellables)\n        }\n\n        // Set up Noise encryption callbacks\n        setupNoiseCallbacks()\n\n        TransferProgressManager.shared.publisher\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] event in\n                self?.handleTransferEvent(event)\n            }\n            .store(in: &cancellables)\n\n        geoChannelCoordinator = GeoChannelCoordinator(\n            onChannelSwitch: { [weak self] channel in\n                self?.switchLocationChannel(to: channel)\n            },\n            beginSampling: { [weak self] geohashes in\n                self?.beginGeohashSampling(for: geohashes)\n            },\n            endSampling: { [weak self] in\n                self?.endGeohashSampling()\n            }\n        )\n\n        // Track teleport flag changes to keep our own teleported marker in sync with regional status\n        LocationChannelManager.shared.$teleported\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] isTeleported in\n                guard let self = self else { return }\n                Task { @MainActor in\n                    guard case .location(let ch) = self.activeChannel,\n                          let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return }\n                    let key = id.publicKeyHex.lowercased()\n                    let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty\n                    let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }\n                    if isTeleported && hasRegional && !inRegional {\n                        self.teleportedGeo = self.teleportedGeo.union([key])\n                    } else {\n                        self.teleportedGeo.remove(key)\n                    }\n                }\n            }\n            .store(in: &cancellables)\n        \n        // Request notification permission (guards test environment internally)\n        NotificationService.shared.requestAuthorization()\n        \n        \n        // Listen for favorite status changes\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(handleFavoriteStatusChanged),\n            name: .favoriteStatusChanged,\n            object: nil\n        )\n        \n        // Listen for peer status updates to refresh UI\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(handlePeerStatusUpdate),\n            name: Notification.Name(\"peerStatusUpdated\"),\n            object: nil\n        )\n        \n        // When app becomes active, send read receipts for visible messages\n        #if os(macOS)\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(appDidBecomeActive),\n            name: NSApplication.didBecomeActiveNotification,\n            object: nil\n        )\n        \n        // Add app lifecycle observers to save data\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(appWillResignActive),\n            name: NSApplication.willResignActiveNotification,\n            object: nil\n        )\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(applicationWillTerminate),\n            name: NSApplication.willTerminateNotification,\n            object: nil\n        )\n        // Tor lifecycle notifications: inform user when Tor restarts and when ready (macOS)\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(handleTorWillRestart),\n            name: .TorWillRestart,\n            object: nil\n        )\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(handleTorDidBecomeReady),\n            name: .TorDidBecomeReady,\n            object: nil\n        )\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(handleTorWillStart),\n            name: .TorWillStart,\n            object: nil\n        )\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(handleTorPreferenceChanged(_:)),\n            name: .TorUserPreferenceChanged,\n            object: nil\n        )\n        #else\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(appDidBecomeActive),\n            name: UIApplication.didBecomeActiveNotification,\n            object: nil\n        )\n        // Resubscribe geohash on app foreground\n        // Resubscribe handled via appDidBecomeActive selector\n        \n        // Add screenshot detection for iOS\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(userDidTakeScreenshot),\n            name: UIApplication.userDidTakeScreenshotNotification,\n            object: nil\n        )\n        \n        // Add app lifecycle observers to save data\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(appWillResignActive),\n            name: UIApplication.willResignActiveNotification,\n            object: nil\n        )\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(applicationWillTerminate),\n            name: UIApplication.willTerminateNotification,\n            object: nil\n        )\n        // Tor lifecycle notifications: inform user when Tor restarts and when ready\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(handleTorWillRestart),\n            name: .TorWillRestart,\n            object: nil\n        )\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(handleTorDidBecomeReady),\n            name: .TorDidBecomeReady,\n            object: nil\n        )\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(handleTorWillStart),\n            name: .TorWillStart,\n            object: nil\n        )\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(handleTorPreferenceChanged(_:)),\n            name: .TorUserPreferenceChanged,\n            object: nil\n        )\n        #endif\n    }\n    \n    // MARK: - Deinitialization\n    \n    deinit {\n        // No need to force UserDefaults synchronization\n    }\n\n\n    \n\n\n\n        \n    // MARK: - Nickname Management\n    \n    private func loadNickname() {\n        if let savedNickname = userDefaults.string(forKey: nicknameKey) {\n            // Trim whitespace when loading\n            nickname = savedNickname.trimmingCharacters(in: .whitespacesAndNewlines)\n        } else {\n            nickname = \"anon\\(Int.random(in: 1000...9999))\"\n            saveNickname()\n        }\n    }\n    \n    func saveNickname() {\n        userDefaults.set(nickname, forKey: nicknameKey)\n        // Persist nickname; no need to force synchronize\n        \n        // Send announce with new nickname to all peers\n        meshService.sendBroadcastAnnounce()\n    }\n    \n    func validateAndSaveNickname() {\n        // Trim whitespace from nickname\n        let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)\n        \n        // Check if nickname is empty after trimming\n        if trimmed.isEmpty {\n            nickname = \"anon\\(Int.random(in: 1000...9999))\"\n        } else {\n            nickname = trimmed\n        }\n        saveNickname()\n    }\n    \n    // MARK: - Favorites Management\n    \n    // MARK: - Blocked Users Management (Delegated to PeerStateManager)\n    \n    \n    /// Check if a peer has unread messages, including messages stored under stable Noise keys and temporary Nostr peer IDs\n    @MainActor\n    func hasUnreadMessages(for peerID: PeerID) -> Bool {\n        // First check direct unread messages\n        if unreadPrivateMessages.contains(peerID) {\n            return true\n        }\n        \n        // Check if messages are stored under the stable Noise key hex\n        if let peer = unifiedPeerService.getPeer(by: peerID) {\n            let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)\n            if unreadPrivateMessages.contains(noiseKeyHex) {\n                return true\n            }\n            // Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey\n            if let nostrHex = peer.nostrPublicKey {\n                let convKey = PeerID(nostr_: nostrHex)\n                if unreadPrivateMessages.contains(convKey) {\n                    return true\n                }\n            }\n        }\n        \n        // Get the peer's nickname to check for temporary Nostr peer IDs\n        let peerNickname = meshService.peerNickname(peerID: peerID)?.lowercased() ?? \"\"\n\n        // Check if any temporary Nostr peer IDs have unread messages from this nickname\n        for unreadPeerID in unreadPrivateMessages {\n            if unreadPeerID.isGeoDM {\n                // Check if messages from this temporary peer match the nickname\n                if let messages = privateChats[unreadPeerID],\n                   let firstMessage = messages.first,\n                   firstMessage.sender.lowercased() == peerNickname {\n                    return true\n                }\n            }\n        }\n        \n        return false\n    }\n    \n    @MainActor\n    func toggleFavorite(peerID: PeerID) {\n        // Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars)\n        // Ephemeral peer IDs are 8 bytes = 16 hex characters\n        // Noise public keys are 32 bytes = 64 hex characters\n        \n        if let noisePublicKey = peerID.noiseKey {\n            // This is a stable Noise key hex (used in private chats)\n            // Find the ephemeral peer ID for this Noise key\n            let ephemeralPeerID = unifiedPeerService.peers.first { peer in\n                peer.noisePublicKey == noisePublicKey\n            }?.peerID\n            \n            if let ephemeralID = ephemeralPeerID {\n                // Found the ephemeral peer, use normal toggle\n                unifiedPeerService.toggleFavorite(ephemeralID)\n                // Also trigger UI update\n                objectWillChange.send()\n            } else {\n                // No ephemeral peer found, directly toggle via FavoritesPersistenceService\n                let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)\n                let wasFavorite = currentStatus?.isFavorite ?? false\n                \n                if wasFavorite {\n                    // Remove favorite\n                    FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)\n                } else {\n                    // Add favorite - get nickname from current status or from private chat messages\n                    var nickname = currentStatus?.peerNickname\n                    \n                    // If no nickname in status, try to get from private chat messages\n                    if nickname == nil, let messages = privateChats[peerID], !messages.isEmpty {\n                        // Get the nickname from the first message where this peer was the sender\n                        nickname = messages.first { $0.senderPeerID == peerID }?.sender\n                    }\n                    \n                    let finalNickname = nickname ?? \"Unknown\"\n                    let nostrKey = currentStatus?.peerNostrPublicKey ?? idBridge.getNostrPublicKey(for: noisePublicKey)\n                    \n                    FavoritesPersistenceService.shared.addFavorite(\n                        peerNoisePublicKey: noisePublicKey,\n                        peerNostrPublicKey: nostrKey,\n                        peerNickname: finalNickname\n                    )\n                }\n                \n                // Trigger UI update\n                objectWillChange.send()\n                \n                // Send favorite notification via Nostr if we're mutual favorites\n                if !wasFavorite && currentStatus?.theyFavoritedUs == true {\n                    // We just favorited them and they already favorite us - send via Nostr\n                    sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: true)\n                } else if wasFavorite {\n                    // We're unfavoriting - send via Nostr if they still favorite us\n                    sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: false)\n                }\n            }\n        } else {\n            // This is an ephemeral peer ID (16 hex chars), use normal toggle\n            unifiedPeerService.toggleFavorite(peerID)\n            // Trigger UI update\n            objectWillChange.send()\n        }\n    }\n    \n    @MainActor\n    func isFavorite(peerID: PeerID) -> Bool {\n        // Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars)\n        if let noisePublicKey = peerID.noiseKey {\n            // This is a Noise public key\n            if let status = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) {\n                return status.isFavorite\n            }\n        } else {\n            // This is an ephemeral peer ID - check with UnifiedPeerService\n            if let peer = unifiedPeerService.getPeer(by: peerID) {\n                return peer.isFavorite\n            }\n        }\n        \n        return false\n    }\n    \n    // MARK: - Public Key and Identity Management\n    \n    @MainActor\n    func isPeerBlocked(_ peerID: PeerID) -> Bool {\n        return unifiedPeerService.isBlocked(peerID)\n    }\n    \n    // Helper method to find current peer ID for a fingerprint\n    @MainActor\n    private func getCurrentPeerIDForFingerprint(_ fingerprint: String) -> PeerID? {\n        // Search through all connected peers to find the one with matching fingerprint\n        for peerID in connectedPeers {\n            if let mappedFingerprint = peerIDToPublicKeyFingerprint[peerID],\n               mappedFingerprint == fingerprint {\n                return peerID\n            }\n        }\n        return nil\n    }\n    \n    // Helper method to update selectedPrivateChatPeer if fingerprint matches\n    @MainActor\n    private func updatePrivateChatPeerIfNeeded() {\n        guard let chatFingerprint = selectedPrivateChatFingerprint else { return }\n        \n        // Find current peer ID for the fingerprint\n        if let currentPeerID = getCurrentPeerIDForFingerprint(chatFingerprint) {\n            // Update the selected peer if it's different\n            if let oldPeerID = selectedPrivateChatPeer, oldPeerID != currentPeerID {\n                \n                // Migrate messages from old peer ID to new peer ID\n                if let oldMessages = privateChats[oldPeerID] {\n                    var chats = privateChats\n                    if chats[currentPeerID] == nil {\n                        chats[currentPeerID] = []\n                    }\n                    chats[currentPeerID]?.append(contentsOf: oldMessages)\n                    // Sort by timestamp\n                    chats[currentPeerID]?.sort { $0.timestamp < $1.timestamp }\n                    \n                    // Remove duplicates\n                    var seen = Set<String>()\n                    chats[currentPeerID] = chats[currentPeerID]?.filter { msg in\n                        if seen.contains(msg.id) {\n                            return false\n                        }\n                        seen.insert(msg.id)\n                        return true\n                    }\n                    \n                    // Remove old peer ID\n                    chats.removeValue(forKey: oldPeerID)\n                    \n                    // Update all at once\n                    privateChats = chats  // Trigger setter\n                }\n                \n                // Migrate unread status\n                if unreadPrivateMessages.contains(oldPeerID) {\n                    unreadPrivateMessages.remove(oldPeerID)\n                    unreadPrivateMessages.insert(currentPeerID)\n                }\n                \n                selectedPrivateChatPeer = currentPeerID\n                \n                // Schedule UI update for encryption status change\n                // UI will update automatically\n                \n                // Also refresh the peer list to update encryption status\n                Task { @MainActor in\n                    // UnifiedPeerService updates automatically via subscriptions\n                }\n            } else if selectedPrivateChatPeer == nil {\n                // Just set the peer ID if we don't have one\n                selectedPrivateChatPeer = currentPeerID\n                // UI will update automatically\n            }\n            \n            // Clear unread messages for the current peer ID\n            unreadPrivateMessages.remove(currentPeerID)\n        }\n    }\n    \n    // MARK: - Message Sending\n    \n    /// Sends a message through the BitChat network.\n    /// - Parameter content: The message content to send\n    /// - Note: Automatically handles command processing if content starts with '/'\n    ///         Routes to private chat if one is selected, otherwise broadcasts\n    @MainActor\n    func sendMessage(_ content: String) {\n        // Ignore messages that are empty or whitespace-only to prevent blank lines\n        let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty else { return }\n\n        // Check for commands\n        if content.hasPrefix(\"/\") {\n            Task { @MainActor in\n                handleCommand(content)\n            }\n            return\n        }\n\n        if selectedPrivateChatPeer != nil {\n            // Update peer ID in case it changed due to reconnection\n            updatePrivateChatPeerIfNeeded()\n\n            if let selectedPeer = selectedPrivateChatPeer {\n                sendPrivateMessage(content, to: selectedPeer)\n            }\n            return\n        }\n\n        // Parse mentions from the content (use original content for user intent)\n        let mentions = parseMentions(from: content)\n\n        var geoContext: GeoOutgoingContext? = nil\n\n        // Add message to local display\n        var displaySender = nickname\n        var localSenderPeerID = meshService.myPeerID\n        var messageID: String? = nil\n        var messageTimestamp = Date()\n\n        switch activeChannel {\n        case .mesh:\n            break\n        case .location(let ch):\n            do {\n                let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)\n                let suffix = String(identity.publicKeyHex.suffix(4))\n                displaySender = nickname + \"#\" + suffix\n                localSenderPeerID = PeerID(nostr: identity.publicKeyHex)\n                let teleported = LocationChannelManager.shared.teleported\n                let event = try NostrProtocol.createEphemeralGeohashEvent(\n                    content: trimmed,\n                    geohash: ch.geohash,\n                    senderIdentity: identity,\n                    nickname: nickname,\n                    teleported: teleported\n                )\n                messageID = event.id\n                messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))\n                geoContext = (channel: ch, event: event, identity: identity, teleported: teleported)\n            } catch {\n                SecureLogger.error(\"❌ Failed to prepare geohash message: \\(error)\", category: .session)\n                addSystemMessage(\n                    String(localized: \"system.location.send_failed\", comment: \"System message when a location channel send fails\")\n                )\n                return\n            }\n        }\n\n        let message = BitchatMessage(\n            id: messageID,\n            sender: displaySender,\n            content: trimmed,\n            timestamp: messageTimestamp,\n            isRelay: false,\n            senderPeerID: localSenderPeerID,\n            mentions: mentions.isEmpty ? nil : mentions\n        )\n\n        timelineStore.append(message, to: activeChannel)\n        refreshVisibleMessages(from: activeChannel)\n\n        // Update content LRU for near-dup detection\n        let ckey = deduplicationService.normalizedContentKey(message.content)\n        deduplicationService.recordContentKey(ckey, timestamp: message.timestamp)\n\n        trimMessagesIfNeeded()\n\n        // UI updates automatically via @Published var messages\n\n        updateChannelActivityTimeThenSend(content: content,\n                                          trimmed: trimmed,\n                                          mentions: mentions,\n                                          geoContext: geoContext,\n                                          messageID: message.id,\n                                          timestamp: message.timestamp)\n    }\n\n    private func updateChannelActivityTimeThenSend(content: String,\n                                                   trimmed: String,\n                                                   mentions: [String],\n                                                   geoContext: GeoOutgoingContext?,\n                                                   messageID: String,\n                                                   timestamp: Date) {\n        switch activeChannel {\n        case .mesh:\n            lastPublicActivityAt[\"mesh\"] = Date()\n            // Send via mesh with mentions\n            meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)\n        case .location(let ch):\n            lastPublicActivityAt[\"geo:\\(ch.geohash)\"] = Date()\n            guard let context = geoContext, context.channel.geohash == ch.geohash else {\n                SecureLogger.error(\"Geo: missing send context for \\(ch.geohash)\", category: .session)\n                addSystemMessage(\n                    String(localized: \"system.location.send_failed\", comment: \"System message when a location channel send fails\")\n                )\n                return\n            }\n            // Send to geohash channel via Nostr ephemeral\n            Task { @MainActor in\n                self.sendGeohash(context: context)\n            }\n        }\n    }\n    \n\n    \n\n    \n\n    \n\n\n    // MARK: - Geohash Participants\n\n    @MainActor\n    func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {\n        guard let peerID else { return false }\n        if peerID == meshService.myPeerID { return true }\n        guard peerID.isGeoDM || peerID.isGeoChat else { return false }\n\n        if let mapped = nostrKeyMapping[peerID]?.lowercased(),\n           let gh = currentGeohash,\n           let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {\n            if mapped == myIdentity.publicKeyHex.lowercased() { return true }\n        }\n\n        if let gh = currentGeohash,\n           let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {\n            if peerID == PeerID(nostr: myIdentity.publicKeyHex) { return true }\n            let suffix = myIdentity.publicKeyHex.suffix(4)\n            let expected = (nickname + \"#\" + suffix).lowercased()\n            if let display = displayName?.lowercased(), display == expected { return true }\n        }\n\n        return false\n    }\n\n    // MARK: - Public helpers\n\n    /// Published geohash people list for SwiftUI observation\n    var geohashPeople: [GeoPerson] {\n        participantTracker.visiblePeople\n    }\n\n    /// Return the current, pruned, sorted people list for the active geohash without mutating state.\n    @MainActor\n    func visibleGeohashPeople() -> [GeoPerson] {\n        participantTracker.getVisiblePeople()\n    }\n\n    /// CommandContextProvider conformance - returns visible geo participants\n    func getVisibleGeoParticipants() -> [CommandGeoParticipant] {\n        visibleGeohashPeople().map { CommandGeoParticipant(id: $0.id, displayName: $0.displayName) }\n    }\n    /// Returns the current participant count for a specific geohash, using the 5-minute activity window.\n    @MainActor\n    func geohashParticipantCount(for geohash: String) -> Int {\n        participantTracker.participantCount(for: geohash)\n    }\n\n    // MARK: - GeohashParticipantContext Protocol\n\n    func displayNameForPubkey(_ pubkeyHex: String) -> String {\n        displayNameForNostrPubkey(pubkeyHex)\n    }\n\n    func isBlocked(_ pubkeyHexLowercased: String) -> Bool {\n        identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)\n    }\n\n    // Geohash block helpers\n    @MainActor\n    func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool {\n        return identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)\n    }\n    @MainActor\n    func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {\n        let hex = pubkeyHexLowercased.lowercased()\n        identityManager.setNostrBlocked(hex, isBlocked: true)\n\n        // Remove from participants for all geohashes\n        participantTracker.removeParticipant(pubkeyHex: hex)\n        \n        // Remove their public messages from current geohash timeline and visible list\n        if let gh = currentGeohash {\n            let predicate: (BitchatMessage) -> Bool = { [self] msg in\n                guard let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat else { return false }\n                if let full = self.nostrKeyMapping[spid]?.lowercased() { return full == hex }\n                return false\n            }\n            timelineStore.removeMessages(in: gh, where: predicate)\n            if case .location = activeChannel {\n                messages.removeAll(where: predicate)\n            }\n        }\n        \n        // Remove geohash DM conversation if exists\n        let convKey = PeerID(nostr_: hex)\n        if privateChats[convKey] != nil {\n            privateChats.removeValue(forKey: convKey)\n            unreadPrivateMessages.remove(convKey)\n        }\n        \n        // Remove mapping keys pointing to this pubkey to avoid accidental resolution\n        for (key, value) in self.nostrKeyMapping where value.lowercased() == hex {\n            self.nostrKeyMapping.removeValue(forKey: key)\n        }\n        \n        addSystemMessage(\n            String(\n                format: String(localized: \"system.geohash.blocked\", comment: \"System message shown when a user is blocked in geohash chats\"),\n                locale: .current,\n                displayName\n            )\n        )\n    }\n    @MainActor\n    func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {\n        identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)\n        addSystemMessage(\n            String(\n                format: String(localized: \"system.geohash.unblocked\", comment: \"System message shown when a user is unblocked in geohash chats\"),\n                locale: .current,\n                displayName\n            )\n        )\n    }\n\n\n\n    func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {\n        let suffix = String(pubkeyHex.suffix(4))\n        // If this is our per-geohash identity, use our nickname\n        if let gh = currentGeohash, let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {\n            if myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() {\n                return nickname + \"#\" + suffix\n            }\n        }\n        // If we have a known nickname tag for this pubkey, use it\n        if let nick = geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty {\n            return nick + \"#\" + suffix\n        }\n        // Otherwise, anonymous with collision-resistant suffix\n        return \"anon#\\(suffix)\"\n    }\n\n\n\n    // MARK: - Media Transfers\n\n    private enum MediaSendError: Error {\n        case encodingFailed\n        case tooLarge\n        case copyFailed\n    }\n\n\n\n\n\n\n\n\n    func currentPublicSender() -> (name: String, peerID: PeerID) {\n        var displaySender = nickname\n        var senderPeerID = meshService.myPeerID\n        if case .location(let ch) = activeChannel,\n           let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {\n            let suffix = String(identity.publicKeyHex.suffix(4))\n            displaySender = nickname + \"#\" + suffix\n            senderPeerID = PeerID(nostr: identity.publicKeyHex)\n        }\n        return (displaySender, senderPeerID)\n    }\n\n    @MainActor\n    func nicknameForPeer(_ peerID: PeerID) -> String {\n        if let name = meshService.peerNickname(peerID: peerID) {\n            return name\n        }\n        if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),\n           !favorite.peerNickname.isEmpty {\n            return favorite.peerNickname\n        }\n        if let noiseKey = Data(hexString: peerID.id),\n           let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),\n           !favorite.peerNickname.isEmpty {\n            return favorite.peerNickname\n        }\n        return \"user\"\n    }\n\n\n\n    @MainActor\n    func removeMessage(withID messageID: String, cleanupFile: Bool = false) {\n        var removedMessage: BitchatMessage?\n\n        if let idx = messages.firstIndex(where: { $0.id == messageID }) {\n            removedMessage = messages.remove(at: idx)\n        }\n\n        if let storeRemoved = timelineStore.removeMessage(withID: messageID) {\n            removedMessage = removedMessage ?? storeRemoved\n        }\n\n        var chats = privateChats\n        for (peerID, items) in chats {\n            let filtered = items.filter { $0.id != messageID }\n            if filtered.count != items.count {\n                if filtered.isEmpty {\n                    chats.removeValue(forKey: peerID)\n                } else {\n                    chats[peerID] = filtered\n                }\n                if removedMessage == nil {\n                    removedMessage = items.first(where: { $0.id == messageID })\n                }\n            }\n        }\n        privateChats = chats\n\n        if cleanupFile, let message = removedMessage {\n            cleanupLocalFile(forMessage: message)\n        }\n\n        objectWillChange.send()\n    }\n\n\n    /// Add a local system message to a private chat (no network send)\n    @MainActor\n    func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {\n        let systemMessage = BitchatMessage(\n            sender: \"system\",\n            content: content,\n            timestamp: Date(),\n            isRelay: false,\n            originalSender: nil,\n            isPrivate: true,\n            recipientNickname: meshService.peerNickname(peerID: peerID),\n            senderPeerID: meshService.myPeerID\n        )\n        if privateChats[peerID] == nil { privateChats[peerID] = [] }\n        privateChats[peerID]?.append(systemMessage)\n        objectWillChange.send()\n    }\n    \n    // MARK: - Bluetooth State Management\n    \n    /// Updates the Bluetooth state and shows appropriate alerts\n    /// - Parameter state: The current Bluetooth manager state\n    @MainActor\n    func updateBluetoothState(_ state: CBManagerState) {\n        bluetoothState = state\n        \n        switch state {\n        case .poweredOff:\n            bluetoothAlertMessage = String(localized: \"content.alert.bluetooth_required.off\", comment: \"Message shown when Bluetooth is turned off\")\n            showBluetoothAlert = true\n        case .unauthorized:\n            bluetoothAlertMessage = String(localized: \"content.alert.bluetooth_required.permission\", comment: \"Message shown when Bluetooth permission is missing\")\n            showBluetoothAlert = true\n        case .unsupported:\n            bluetoothAlertMessage = String(localized: \"content.alert.bluetooth_required.unsupported\", comment: \"Message shown when the device lacks Bluetooth support\")\n            showBluetoothAlert = true\n        case .poweredOn:\n            // Hide alert when Bluetooth is powered on\n            showBluetoothAlert = false\n            bluetoothAlertMessage = \"\"\n        case .unknown, .resetting:\n            // Don't show alerts for transient states\n            showBluetoothAlert = false\n        @unknown default:\n            showBluetoothAlert = false\n        }\n    }\n    \n    // MARK: - Private Chat Management\n\n    /// Initiates a private chat session with a peer.\n    /// - Parameter peerID: The peer's ID to start chatting with\n    /// - Note: Switches the UI to private chat mode and loads message history\n    @MainActor\n    func startPrivateChat(with peerID: PeerID) {\n        // Safety check: Don't allow starting chat with ourselves\n        if peerID == meshService.myPeerID {\n            return\n        }\n\n        let peerNickname = meshService.peerNickname(peerID: peerID) ?? \"unknown\"\n\n        // Check if the peer is blocked\n        if unifiedPeerService.isBlocked(peerID) {\n            addSystemMessage(\n                String(\n                    format: String(localized: \"system.chat.blocked\", comment: \"System message when starting chat fails because peer is blocked\"),\n                    locale: .current,\n                    peerNickname\n                )\n            )\n            return\n        }\n\n        // Check mutual favorites for offline messaging\n        if let peer = unifiedPeerService.getPeer(by: peerID),\n           peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {\n            addSystemMessage(\n                String(\n                    format: String(localized: \"system.chat.requires_favorite\", comment: \"System message when mutual favorite requirement blocks chat\"),\n                    locale: .current,\n                    peerNickname\n                )\n            )\n            return\n        }\n\n        // Consolidate messages from different peer ID representations (stable Noise key, temp Nostr IDs)\n        // Pass persisted sentReadReceipts to correctly identify already-read messages after app restart\n        _ = privateChatManager.consolidateMessages(for: peerID, peerNickname: peerNickname, persistedReadReceipts: sentReadReceipts)\n\n        // Trigger handshake if needed (mesh peers only). Skip for Nostr geohash conv keys.\n        if !peerID.isGeoDM && !peerID.isGeoChat {\n            let sessionState = meshService.getNoiseSessionState(for: peerID)\n            switch sessionState {\n            case .none, .failed:\n                meshService.triggerHandshake(with: peerID)\n            case .handshakeQueued, .handshaking, .established:\n                break\n            }\n        } else {\n            SecureLogger.debug(\"GeoDM: skipping mesh handshake for virtual peerID=\\(peerID)\", category: .session)\n        }\n\n        // Sync read receipt tracking to prevent duplicates\n        privateChatManager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: nickname, externalReceipts: &sentReadReceipts)\n\n        privateChatManager.startChat(with: peerID)\n\n        // Also mark messages as read for Nostr ACKs\n        // This ensures read receipts are sent even for consolidated messages\n        markPrivateMessagesAsRead(from: peerID)\n    }\n    \n    func endPrivateChat() {\n        selectedPrivateChatPeer = nil\n        selectedPrivateChatFingerprint = nil\n    }\n    \n    @MainActor\n    @objc private func handlePeerStatusUpdate(_ notification: Notification) {\n        // Update private chat peer if needed when peer status changes\n        updatePrivateChatPeerIfNeeded()\n    }\n    \n    @objc private func handleFavoriteStatusChanged(_ notification: Notification) {\n        guard let peerPublicKey = notification.userInfo?[\"peerPublicKey\"] as? Data else { return }\n        \n        Task { @MainActor in\n            // Handle noise key updates\n            if let isKeyUpdate = notification.userInfo?[\"isKeyUpdate\"] as? Bool,\n               isKeyUpdate,\n               let oldKey = notification.userInfo?[\"oldPeerPublicKey\"] as? Data {\n                let oldPeerID = PeerID(hexData: oldKey)\n                let newPeerID = PeerID(hexData: peerPublicKey)\n                \n                // If we have a private chat open with the old peer ID, update it to the new one\n                if selectedPrivateChatPeer == oldPeerID {\n                    SecureLogger.info(\"📱 Updating private chat peer ID due to key change: \\(oldPeerID) -> \\(newPeerID)\", category: .session)\n                    \n                    // Transfer private chat messages to new peer ID\n                    if let messages = privateChats[oldPeerID] {\n                        var chats = privateChats\n                        chats[newPeerID] = messages\n                        chats.removeValue(forKey: oldPeerID)\n                        privateChats = chats  // Trigger setter\n                    }\n                    \n                    // Transfer unread status\n                    if unreadPrivateMessages.contains(oldPeerID) {\n                        unreadPrivateMessages.remove(oldPeerID)\n                        unreadPrivateMessages.insert(newPeerID)\n                    }\n                    \n                    // Update selected peer\n                    selectedPrivateChatPeer = newPeerID\n                    \n                    // Update fingerprint tracking if needed\n                    if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] {\n                        peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID)\n                        peerIDToPublicKeyFingerprint[newPeerID] = fingerprint\n                        selectedPrivateChatFingerprint = fingerprint\n                    }\n                    \n                    // Schedule UI refresh\n                    // UI will update automatically\n                } else {\n                    // Even if the chat isn't open, migrate any existing private chat data\n                    if let messages = privateChats[oldPeerID] {\n                        SecureLogger.debug(\"📱 Migrating private chat messages from \\(oldPeerID) to \\(newPeerID)\", category: .session)\n                        var chats = privateChats\n                        chats[newPeerID] = messages\n                        chats.removeValue(forKey: oldPeerID)\n                        privateChats = chats  // Trigger setter\n                    }\n                    \n                    // Transfer unread status\n                    if unreadPrivateMessages.contains(oldPeerID) {\n                        unreadPrivateMessages.remove(oldPeerID)\n                        unreadPrivateMessages.insert(newPeerID)\n                    }\n                    \n                    // Update fingerprint mapping\n                    if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] {\n                        peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID)\n                        peerIDToPublicKeyFingerprint[newPeerID] = fingerprint\n                    }\n                }\n            }\n            \n            // First check if this is a peer ID update for our current chat\n            updatePrivateChatPeerIfNeeded()\n            \n            // Then handle favorite/unfavorite messages if applicable\n            if let isFavorite = notification.userInfo?[\"isFavorite\"] as? Bool {\n                let peerID = PeerID(hexData: peerPublicKey)\n                let action = isFavorite ? \"favorited\" : \"unfavorited\"\n                \n                // Find peer nickname\n                let peerNickname: String\n                if let nickname = meshService.peerNickname(peerID: peerID) {\n                    peerNickname = nickname\n                } else if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) {\n                    peerNickname = favorite.peerNickname\n                } else {\n                    peerNickname = \"Unknown\"\n                }\n                \n                // Create system message\n                let systemMessage = BitchatMessage(\n                    id: UUID().uuidString,\n                sender: \"System\",\n                content: \"\\(peerNickname) \\(action) you\",\n                timestamp: Date(),\n                isRelay: false,\n                originalSender: nil,\n                isPrivate: false,\n                recipientNickname: nil,\n                senderPeerID: nil,\n                mentions: nil\n            )\n            \n            // Add to message stream\n            addMessage(systemMessage)\n            \n            // Update peer manager to refresh UI\n            // UnifiedPeerService updates automatically via subscriptions\n            }\n        }\n    }\n    \n    // MARK: - App Lifecycle\n    \n    @MainActor\n    @objc private func appDidBecomeActive() {\n        // Check Bluetooth state and show alert if needed\n        if let bleService = meshService as? BLEService {\n            let currentState = bleService.getCurrentBluetoothState()\n            updateBluetoothState(currentState)\n        }\n\n        // When app becomes active, send read receipts for visible private chat\n        if let peerID = selectedPrivateChatPeer {\n            // Try immediately\n            self.markPrivateMessagesAsRead(from: peerID)\n            // And again with a delay\n            DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) {\n                self.markPrivateMessagesAsRead(from: peerID)\n            }\n        }\n        // Subscriptions will be resent after connections come back up\n    }\n    \n    @MainActor\n    @objc private func userDidTakeScreenshot() {\n        // Respect privacy: do not broadcast screenshots taken from non-chat sheets\n        if isLocationChannelsSheetPresented {\n            // Show a warning about sharing location screenshots publicly\n            showScreenshotPrivacyWarning = true\n            return\n        }\n        if isAppInfoPresented {\n            // Silently ignore screenshots of app info\n            return\n        }\n\n        // Send screenshot notification based on current context\n        let screenshotMessage = \"* \\(nickname) took a screenshot *\"\n        \n        if let peerID = selectedPrivateChatPeer {\n            // In private chat - send to the other person\n            if let peerNickname = meshService.peerNickname(peerID: peerID) {\n                // Only send screenshot notification if we have an established session\n                // This prevents triggering handshake requests for screenshot notifications\n                let sessionState = meshService.getNoiseSessionState(for: peerID)\n                switch sessionState {\n                case .established:\n                    // Send the message directly without going through sendPrivateMessage to avoid local echo\n                    messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)\n                case  .none, .failed, .handshakeQueued, .handshaking:\n                    // Don't send screenshot notification if no session exists\n                    SecureLogger.debug(\"Skipping screenshot notification to \\(peerID) - no established session\", category: .security)\n                }\n            }\n            \n            // Show local notification immediately as system message (only in chat)\n            let localNotification = BitchatMessage(\n                sender: \"system\",\n                content: \"you took a screenshot\",\n                timestamp: Date(),\n                isRelay: false,\n                originalSender: nil,\n                isPrivate: true,\n                recipientNickname: meshService.peerNickname(peerID: peerID),\n                senderPeerID: meshService.myPeerID\n            )\n            var chats = privateChats\n            if chats[peerID] == nil {\n                chats[peerID] = []\n            }\n            chats[peerID]?.append(localNotification)\n            privateChats = chats  // Trigger setter\n            \n        } else {\n            // In public chat - send to active public channel\n            switch activeChannel {\n            case .mesh:\n                meshService.sendMessage(screenshotMessage,\n                                        mentions: [],\n                                        messageID: UUID().uuidString,\n                                        timestamp: Date())\n            case .location(let ch):\n                Task { @MainActor in\n                    do {\n                        let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)\n                        let event = try NostrProtocol.createEphemeralGeohashEvent(\n                            content: screenshotMessage,\n                            geohash: ch.geohash,\n                            senderIdentity: identity,\n                            nickname: self.nickname,\n                            teleported: LocationChannelManager.shared.teleported\n                        )\n                        let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)\n                        if targetRelays.isEmpty {\n                            SecureLogger.warning(\"Geo: no geohash relays available for \\(ch.geohash); not sending\", category: .session)\n                        } else {\n                            NostrRelayManager.shared.sendEvent(event, to: targetRelays)\n                        }\n                        // Track ourselves as active participant\n                        self.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)\n                    } catch {\n                        SecureLogger.error(\"❌ Failed to send geohash screenshot message: \\(error)\", category: .session)\n                        self.addSystemMessage(\n                            String(localized: \"system.location.send_failed\", comment: \"System message when a location channel send fails\")\n                        )\n                    }\n                }\n            }\n            \n\n            // Show local notification immediately as system message (only in chat)\n            let localNotification = BitchatMessage(\n                sender: \"system\",\n                content: \"you took a screenshot\",\n                timestamp: Date(),\n                isRelay: false\n            )\n            // Add system message\n            addMessage(localNotification)\n        }\n    }\n    \n    @objc private func appWillResignActive() {\n        // No-op; avoid forcing synchronize on resign\n    }\n    \n    /// Save identity state without stopping services (for backgrounding)\n    func saveIdentityState() {\n        // Force save any pending identity changes (verifications, favorites, etc)\n        identityManager.forceSave()\n\n        // Verify identity key is still there\n        _ = keychain.verifyIdentityKeyExists()\n    }\n\n    @objc func applicationWillTerminate() {\n        // Send leave message to all peers\n        meshService.stopServices()\n\n        // Save identity state\n        saveIdentityState()\n    }\n    \n    @MainActor\n    private func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID, originalTransport: String? = nil) {\n        // First, try to resolve the current peer ID in case they reconnected with a new ID\n        var actualPeerID = peerID\n        \n        // Check if this peer ID exists in current nicknames\n        if meshService.peerNickname(peerID: peerID) == nil {\n            // Peer not found with this ID, try to find by fingerprint or nickname\n            if let oldNoiseKey = Data(hexString: peerID.id),\n               let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: oldNoiseKey) {\n                let peerNickname = favoriteStatus.peerNickname\n                \n                // Search for the current peer ID with the same nickname\n                for (currentPeerID, currentNickname) in meshService.getPeerNicknames() {\n                    if currentNickname == peerNickname {\n                        SecureLogger.info(\"📖 Resolved updated peer ID for read receipt: \\(peerID) -> \\(currentPeerID)\", category: .session)\n                        actualPeerID = currentPeerID\n                        break\n                    }\n                }\n            }\n        }\n        \n        // If this originated over Nostr, skip (handled by Nostr code paths)\n        if originalTransport == \"nostr\" {\n            return\n        }\n        // Use router to decide (mesh if reachable, else Nostr if available)\n        messageRouter.sendReadReceipt(receipt, to: actualPeerID)\n    }\n    \n    @MainActor\n    func markPrivateMessagesAsRead(from peerID: PeerID) {\n        privateChatManager.markAsRead(from: peerID)\n        \n        // Handle GeoDM (nostr_*) read receipts directly via per-geohash identity\n        if peerID.isGeoDM,\n           let recipientHex = nostrKeyMapping[peerID],\n           case .location(let ch) = LocationChannelManager.shared.selectedChannel,\n           let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {\n            let messages = privateChats[peerID] ?? []\n            for message in messages where message.senderPeerID == peerID && !message.isRelay {\n                if !sentReadReceipts.contains(message.id) {\n                    SecureLogger.debug(\"GeoDM: sending READ for mid=\\(message.id.prefix(8))… to=\\(recipientHex.prefix(8))…\", category: .session)\n                    let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)\n                    nostrTransport.senderPeerID = meshService.myPeerID\n                    nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id)\n                    sentReadReceipts.insert(message.id)\n                }\n            }\n            return\n        }\n\n        // Get the peer's Noise key to check for Nostr messages\n        var noiseKeyHex: PeerID? = nil\n        var peerNostrPubkey: String? = nil\n        \n        // First check if peerID is already a hex Noise key\n        if let noiseKey = Data(hexString: peerID.id),\n           let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {\n            noiseKeyHex = peerID\n            peerNostrPubkey = favoriteStatus.peerNostrPublicKey\n        }\n        // Otherwise get the Noise key from the peer info\n        else if let peer = unifiedPeerService.getPeer(by: peerID) {\n            noiseKeyHex = PeerID(hexData: peer.noisePublicKey)\n            let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey)\n            peerNostrPubkey = favoriteStatus?.peerNostrPublicKey\n            \n            // Also remove unread status from the stable Noise key if it exists\n            if let keyHex = noiseKeyHex, unreadPrivateMessages.contains(keyHex) {\n                unreadPrivateMessages.remove(keyHex)\n            }\n        }\n        \n        // Send Nostr read ACKs if peer has Nostr capability\n        if peerNostrPubkey != nil {\n            // Check messages under both ephemeral peer ID and stable Noise key\n            let messagesToAck = getPrivateChatMessages(for: peerID)\n            \n            for message in messagesToAck {\n                // Only send read ACKs for messages from the peer (not our own)\n                // Check both the ephemeral peer ID and stable Noise key as sender\n                if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay {\n                    // Skip if we already sent an ACK for this message\n                    if !sentReadReceipts.contains(message.id) {\n                        // Use stable Noise key hex if available; else fall back to peerID\n                        let recipPeer = peerID.isHex ? peerID : (unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID)\n                        let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)\n                        messageRouter.sendReadReceipt(receipt, to: recipPeer)\n                        sentReadReceipts.insert(message.id)\n                    }\n                }\n            }\n        }\n    }\n    \n    @MainActor\n    func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {\n        var combined: [BitchatMessage] = []\n\n        // Gather messages under the ephemeral peer ID\n        if let ephemeralMessages = privateChats[peerID] {\n            combined.append(contentsOf: ephemeralMessages)\n        }\n\n        // Also include messages stored under the stable Noise key (Nostr path)\n        if let peer = unifiedPeerService.getPeer(by: peerID) {\n            let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)\n            if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex] {\n                combined.append(contentsOf: nostrMessages)\n            }\n        }\n\n        // De-duplicate by message ID: keep the item with the most advanced delivery status.\n        // This prevents duplicate IDs causing LazyVStack warnings and blank rows, and ensures\n        // we show the row whose status has already progressed to delivered/read.\n        func statusRank(_ s: DeliveryStatus?) -> Int {\n            guard let s = s else { return 0 }\n            switch s {\n            case .failed: return 1\n            case .sending: return 2\n            case .sent: return 3\n            case .partiallyDelivered: return 4\n            case .delivered: return 5\n            case .read: return 6\n            }\n        }\n\n        var bestByID: [String: BitchatMessage] = [:]\n        for msg in combined {\n            if let existing = bestByID[msg.id] {\n                let lhs = statusRank(existing.deliveryStatus)\n                let rhs = statusRank(msg.deliveryStatus)\n                if rhs > lhs || (rhs == lhs && msg.timestamp > existing.timestamp) {\n                    bestByID[msg.id] = msg\n                }\n            } else {\n                bestByID[msg.id] = msg\n            }\n        }\n\n        // Return chronologically sorted, de-duplicated list\n        return bestByID.values.sorted { $0.timestamp < $1.timestamp }\n    }\n    \n    @MainActor\n    func getPeerIDForNickname(_ nickname: String) -> PeerID? {\n        // When in a geohash channel, allow resolving by geohash participant nickname\n        switch LocationChannelManager.shared.selectedChannel {\n        case .location:\n            // If a disambiguation suffix is present (e.g., \"name#abcd\"), try exact displayName match first\n            if nickname.contains(\"#\") {\n                if let person = visibleGeohashPeople().first(where: { $0.displayName == nickname }) {\n                    let convKey = PeerID(nostr_: person.id)\n                    nostrKeyMapping[convKey] = person.id\n                    return convKey\n                }\n            }\n            let base: String = {\n                if let hashIndex = nickname.firstIndex(of: \"#\") { return String(nickname[..<hashIndex]) }\n                return nickname\n            }().lowercased()\n            // Try exact match against cached geoNicknames (pubkey -> nickname)\n            if let pub = geoNicknames.first(where: { (_, nick) in nick.lowercased() == base })?.key {\n                let convKey = PeerID(nostr_: pub)\n                nostrKeyMapping[convKey] = pub\n                return convKey\n            }\n        case .mesh:\n            break\n        }\n        // Fallback to mesh nickname resolution\n        return unifiedPeerService.getPeerID(for: nickname)\n    }\n    \n    \n    // MARK: - Emergency Functions\n    \n    // PANIC: Emergency data clearing for activist safety\n    @MainActor\n    func panicClearAllData() {\n        // Messages are processed immediately - nothing to flush\n        \n        // Clear all messages\n        messages.removeAll()\n        privateChatManager.privateChats.removeAll()\n        privateChatManager.unreadMessages.removeAll()\n        \n        // Delete all keychain data (including Noise and Nostr keys)\n        _ = keychain.deleteAllKeychainData()\n        \n        // Clear UserDefaults identity data\n        userDefaults.removeObject(forKey: \"bitchat.noiseIdentityKey\")\n        userDefaults.removeObject(forKey: \"bitchat.messageRetentionKey\")\n        \n        // Clear verified fingerprints\n        verifiedFingerprints.removeAll()\n        // Verified fingerprints are cleared when identity data is cleared below\n        \n        // Reset nickname to anonymous\n        nickname = \"anon\\(Int.random(in: 1000...9999))\"\n        saveNickname()\n        \n        // Clear favorites and peer mappings\n        // Clear through SecureIdentityStateManager instead of directly\n        identityManager.clearAllIdentityData()\n        peerIDToPublicKeyFingerprint.removeAll()\n        \n        // Clear persistent favorites from keychain\n        FavoritesPersistenceService.shared.clearAllFavorites()\n        \n        // Identity manager has cleared persisted identity data above\n        \n        // Clear autocomplete state\n        autocompleteSuggestions.removeAll()\n        showAutocomplete = false\n        autocompleteRange = nil\n        selectedAutocompleteIndex = 0\n        \n        // Clear selected private chat\n        selectedPrivateChatPeer = nil\n        selectedPrivateChatFingerprint = nil\n        \n        // Clear read receipt tracking\n        sentReadReceipts.removeAll()\n        deduplicationService.clearAll()\n\n        // Clear all caches\n        invalidateEncryptionCache()\n        \n        // IMPORTANT: Clear Nostr-related state\n        // Disconnect from Nostr relays and clear subscriptions\n        nostrRelayManager?.disconnect()\n        nostrRelayManager = nil\n        \n        // Clear Nostr identity associations\n        idBridge.clearAllAssociations()\n        \n        // Disconnect from all peers and clear persistent identity\n        // This will force creation of a new identity (new fingerprint) on next launch\n        meshService.emergencyDisconnectAll()\n        if let bleService = meshService as? BLEService {\n            bleService.resetIdentityForPanic(currentNickname: nickname)\n        }\n        \n        // No need to force UserDefaults synchronization\n        \n        // Reinitialize Nostr with new identity\n        // This will generate new Nostr keys derived from new Noise keys\n        Task { @MainActor in\n            // Small delay to ensure cleanup completes\n            try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds\n            \n            // Reinitialize Nostr relay manager with new identity\n            nostrRelayManager = NostrRelayManager()\n            setupNostrMessageHandling()\n            nostrRelayManager?.connect()\n        }\n        \n        // Delete ALL media files (incoming and outgoing) in background\n        Task.detached(priority: .utility) {\n            do {\n                let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)\n                let filesDir = base.appendingPathComponent(\"files\", isDirectory: true)\n\n                // Delete the entire files directory and recreate it\n                if FileManager.default.fileExists(atPath: filesDir.path) {\n                    try FileManager.default.removeItem(at: filesDir)\n                    SecureLogger.info(\"🗑️ Deleted all media files during panic clear\", category: .session)\n                }\n\n                // Recreate empty directory structure\n                try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)\n                try FileManager.default.createDirectory(at: filesDir.appendingPathComponent(\"voicenotes/incoming\", isDirectory: true), withIntermediateDirectories: true, attributes: nil)\n                try FileManager.default.createDirectory(at: filesDir.appendingPathComponent(\"voicenotes/outgoing\", isDirectory: true), withIntermediateDirectories: true, attributes: nil)\n                try FileManager.default.createDirectory(at: filesDir.appendingPathComponent(\"images/incoming\", isDirectory: true), withIntermediateDirectories: true, attributes: nil)\n                try FileManager.default.createDirectory(at: filesDir.appendingPathComponent(\"images/outgoing\", isDirectory: true), withIntermediateDirectories: true, attributes: nil)\n                try FileManager.default.createDirectory(at: filesDir.appendingPathComponent(\"files/incoming\", isDirectory: true), withIntermediateDirectories: true, attributes: nil)\n                try FileManager.default.createDirectory(at: filesDir.appendingPathComponent(\"files/outgoing\", isDirectory: true), withIntermediateDirectories: true, attributes: nil)\n            } catch {\n                SecureLogger.error(\"Failed to clear media files during panic: \\(error)\", category: .session)\n            }\n\n            // BCH-01-013: Clear iOS app switcher snapshots\n            // These are stored in Library/Caches/Snapshots/<bundle_id>/\n            #if os(iOS)\n            Self.clearAppSwitcherSnapshots()\n            #endif\n        }\n\n        // Force immediate UI update for panic mode\n        // UI updates immediately - no flushing needed\n\n    }\n\n    /// BCH-01-013: Clear iOS app switcher snapshots during panic mode\n    /// iOS stores preview screenshots in Library/Caches/Snapshots/<bundle_id>/\n    /// These could reveal sensitive information visible in the app at the time\n    #if os(iOS)\n    private nonisolated static func clearAppSwitcherSnapshots() {\n        do {\n            let cacheDir = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false)\n            let snapshotsDir = cacheDir.appendingPathComponent(\"Snapshots\", isDirectory: true)\n\n            // Clear all snapshots (iOS stores them in subdirectories by bundle ID and scene)\n            if FileManager.default.fileExists(atPath: snapshotsDir.path) {\n                let contents = try FileManager.default.contentsOfDirectory(at: snapshotsDir, includingPropertiesForKeys: nil)\n                for item in contents {\n                    try FileManager.default.removeItem(at: item)\n                }\n                SecureLogger.info(\"🗑️ Cleared app switcher snapshots during panic clear\", category: .session)\n            }\n        } catch {\n            SecureLogger.error(\"Failed to clear app switcher snapshots: \\(error)\", category: .session)\n        }\n    }\n    #endif\n\n    // MARK: - Autocomplete\n    \n    func updateAutocomplete(for text: String, cursorPosition: Int) {\n        // Build candidate list based on active channel\n        let peerCandidates: [String] = {\n            switch activeChannel {\n            case .mesh:\n                let values = meshService.getPeerNicknames().values\n                return Array(values.filter { $0 != meshService.myNickname })\n            case .location(let ch):\n                // From geochash participants we have seen via Nostr events\n                var tokens = Set<String>()\n                for (pubkey, nick) in geoNicknames {\n                    let suffix = String(pubkey.suffix(4))\n                    tokens.insert(\"\\(nick)#\\(suffix)\")\n                }\n                // Optionally exclude self nick#abcd from suggestions\n                if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {\n                    let myToken = nickname + \"#\" + String(id.publicKeyHex.suffix(4))\n                    tokens.remove(myToken)\n                }\n                return Array(tokens)\n            }\n        }()\n\n        let (suggestions, range) = autocompleteService.getSuggestions(\n            for: text,\n            peers: peerCandidates,\n            cursorPosition: cursorPosition\n        )\n        \n        if !suggestions.isEmpty {\n            autocompleteSuggestions = suggestions\n            autocompleteRange = range\n            showAutocomplete = true\n            selectedAutocompleteIndex = 0\n        } else {\n            autocompleteSuggestions = []\n            autocompleteRange = nil\n            showAutocomplete = false\n            selectedAutocompleteIndex = 0\n        }\n    }\n    \n    func completeNickname(_ nickname: String, in text: inout String) -> Int {\n        guard let range = autocompleteRange else { return text.count }\n        \n        text = autocompleteService.applySuggestion(nickname, to: text, range: range)\n        \n        // Hide autocomplete\n        showAutocomplete = false\n        autocompleteSuggestions = []\n        autocompleteRange = nil\n        selectedAutocompleteIndex = 0\n        \n        // Return new cursor position\n        return range.location + nickname.count + (nickname.hasPrefix(\"@\") ? 1 : 2)\n    }\n    \n    // MARK: - Message Formatting\n    \n    @MainActor\n    func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {\n        // Determine if this message was sent by self (mesh, geo, or DM)\n        let isSelf: Bool = {\n            if let spid = message.senderPeerID {\n                // In geohash channels, compare against our per-geohash nostr short ID\n                if case .location(let ch) = activeChannel, spid.isGeoChat {\n                    let myGeo: NostrIdentity? = {\n                        if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash {\n                            return cached.identity\n                        }\n                        // Fallback: derive and cache (should rarely happen)\n                        if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {\n                            cachedGeohashIdentity = (ch.geohash, identity)\n                            return identity\n                        }\n                        return nil\n                    }()\n                    if let myGeo {\n                        return spid == PeerID(nostr: myGeo.publicKeyHex)\n                    }\n                }\n                return spid == meshService.myPeerID\n            }\n            // Fallback by nickname\n            if message.sender == nickname { return true }\n            if message.sender.hasPrefix(nickname + \"#\") { return true }\n            return false\n        }()\n        // Check cache first (key includes dark mode + self flag)\n        let isDark = colorScheme == .dark\n        if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {\n            return cachedText\n        }\n        \n        // Not cached, format the message\n        var result = AttributedString()\n        \n        let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)\n        \n        if message.sender != \"system\" {\n            // Sender (at the beginning) with light-gray suffix styling if present\n            let (baseName, suffix) = message.sender.splitSuffix()\n            var senderStyle = AttributeContainer()\n            // Use consistent color for all senders\n            senderStyle.foregroundColor = baseColor\n            // Bold the user's own nickname\n            let fontWeight: Font.Weight = isSelf ? .bold : .medium\n            senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)\n            // Make sender clickable: encode senderPeerID into a custom URL\n            if let spid = message.senderPeerID, let url = URL(string: \"bitchat://user/\\(spid.toPercentEncoded())\") {\n                senderStyle.link = url\n            }\n\n            // Prefix \"<@\"\n            result.append(AttributedString(\"<@\").mergingAttributes(senderStyle))\n            // Base name\n            result.append(AttributedString(baseName).mergingAttributes(senderStyle))\n            // Optional suffix in lighter variant of the base color (green or orange for self)\n            if !suffix.isEmpty {\n                var suffixStyle = senderStyle\n                suffixStyle.foregroundColor = baseColor.opacity(0.6)\n                result.append(AttributedString(suffix).mergingAttributes(suffixStyle))\n            }\n            // Suffix \"> \"\n            result.append(AttributedString(\"> \").mergingAttributes(senderStyle))\n            \n            // Process content with hashtags and mentions\n            let content = message.content\n            \n            // For extremely long content, render as plain text to avoid heavy regex/layout work,\n            // unless the content includes Cashu tokens we want to chip-render below\n            // Compute NSString-backed length for regex/nsrange correctness with multi-byte characters\n            let nsContent = content as NSString\n            let nsLen = nsContent.length\n            let containsCashuEarly: Bool = {\n                let rx = Patterns.quickCashuPresence\n                return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0\n            }()\n            if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {\n                var plainStyle = AttributeContainer()\n                plainStyle.foregroundColor = baseColor\n                plainStyle.font = isSelf\n                    ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)\n                    : .bitchatSystem(size: 14, design: .monospaced)\n                result.append(AttributedString(content).mergingAttributes(plainStyle))\n            } else {\n            // Reuse compiled regexes and detector from MessageFormattingEngine\n            let hashtagRegex = Patterns.hashtag\n            let mentionRegex = Patterns.mention\n            let cashuRegex = Patterns.cashu\n            let bolt11Regex = Patterns.bolt11\n            let lnurlRegex = Patterns.lnurl\n            let lightningSchemeRegex = Patterns.lightningScheme\n            let detector = Patterns.linkDetector\n            let hasMentionsHint = content.contains(\"@\")\n            let hasHashtagsHint = content.contains(\"#\")\n            let hasURLHint = content.contains(\"://\") || content.contains(\"www.\") || content.contains(\"http\")\n            let hasLightningHint = content.lowercased().contains(\"ln\") || content.lowercased().contains(\"lightning:\")\n            let hasCashuHint = content.lowercased().contains(\"cashu\")\n\n            let hashtagMatches = hasHashtagsHint ? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []\n            let mentionMatches = hasMentionsHint ? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []\n            let urlMatches = hasURLHint ? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : []\n            let cashuMatches = hasCashuHint ? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []\n            let lightningMatches = hasLightningHint ? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []\n            let bolt11Matches = hasLightningHint ? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []\n            let lnurlMatches = hasLightningHint ? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []\n            \n            // Combine and sort matches, excluding hashtags/URLs overlapping mentions\n            let mentionRanges = mentionMatches.map { $0.range(at: 0) }\n            func overlapsMention(_ r: NSRange) -> Bool {\n                for mr in mentionRanges { if NSIntersectionRange(r, mr).length > 0 { return true } }\n                return false\n            }\n            // Helper: check if a hashtag is immediately attached to a preceding @mention (e.g., @name#abcd)\n            func attachedToMention(_ r: NSRange) -> Bool {\n                if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex {\n                    var i = content.index(before: nsRange.lowerBound)\n                    while true {\n                        let ch = content[i]\n                        if ch.isWhitespace || ch.isNewline { break }\n                        if ch == \"@\" { return true }\n                        if i == content.startIndex { break }\n                        i = content.index(before: i)\n                    }\n                }\n                return false\n            }\n            // Helper: ensure '#' starts a new token (start-of-line or whitespace before '#')\n            func isStandaloneHashtag(_ r: NSRange) -> Bool {\n                guard let nsRange = Range(r, in: content) else { return false }\n                if nsRange.lowerBound == content.startIndex { return true }\n                let prev = content.index(before: nsRange.lowerBound)\n                return content[prev].isWhitespace || content[prev].isNewline\n            }\n            var allMatches: [(range: NSRange, type: String)] = []\n            for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) {\n                allMatches.append((match.range(at: 0), \"hashtag\"))\n            }\n            for match in mentionMatches {\n                allMatches.append((match.range(at: 0), \"mention\"))\n            }\n            for match in urlMatches where !overlapsMention(match.range) {\n                allMatches.append((match.range, \"url\"))\n            }\n            for match in cashuMatches where !overlapsMention(match.range(at: 0)) {\n                allMatches.append((match.range(at: 0), \"cashu\"))\n            }\n            // Lightning scheme first to avoid overlapping submatches\n            for match in lightningMatches where !overlapsMention(match.range(at: 0)) {\n                allMatches.append((match.range(at: 0), \"lightning\"))\n            }\n            // Exclude overlaps with lightning/url for bolt11/lnurl\n            let occupied: [NSRange] = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }\n            func overlapsOccupied(_ r: NSRange) -> Bool {\n                for or in occupied { if NSIntersectionRange(r, or).length > 0 { return true } }\n                return false\n            }\n            for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {\n                allMatches.append((match.range(at: 0), \"bolt11\"))\n            }\n            for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {\n                allMatches.append((match.range(at: 0), \"lnurl\"))\n            }\n            allMatches.sort { $0.range.location < $1.range.location }\n            \n            // Build content with styling\n            var lastEnd = content.startIndex\n            let isMentioned = message.mentions?.contains(nickname) ?? false\n            \n            for (range, type) in allMatches {\n                // Add text before match\n                if let nsRange = Range(range, in: content) {\n                    if lastEnd < nsRange.lowerBound {\n                        let beforeText = String(content[lastEnd..<nsRange.lowerBound])\n                        if !beforeText.isEmpty {\n                            var beforeStyle = AttributeContainer()\n                            beforeStyle.foregroundColor = baseColor\n                            beforeStyle.font = isSelf\n                                ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)\n                                : .bitchatSystem(size: 14, design: .monospaced)\n                            if isMentioned {\n                                beforeStyle.font = beforeStyle.font?.bold()\n                            }\n                            result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))\n                        }\n                    }\n                    \n                    // Add styled match\n                    let matchText = String(content[nsRange])\n                    if type == \"mention\" {\n                        // Split optional '#abcd' suffix and color suffix light grey\n                        let (mBase, mSuffix) = matchText.splitSuffix()\n                        // Determine if this mention targets me (resolves with optional suffix per active channel)\n                        let mySuffix: String? = {\n                            if case .location(let ch) = activeChannel, let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {\n                                return String(id.publicKeyHex.suffix(4))\n                            }\n                            return String(meshService.myPeerID.id.prefix(4))\n                        }()\n                        let isMentionToMe: Bool = {\n                            if mBase == nickname {\n                                if let suf = mySuffix, !mSuffix.isEmpty {\n                                    return mSuffix == \"#\\(suf)\"\n                                }\n                                return mSuffix.isEmpty\n                            }\n                            return false\n                        }()\n                        var mentionStyle = AttributeContainer()\n                        mentionStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced)\n                        let mentionColor: Color = isMentionToMe ? .orange : baseColor\n                        mentionStyle.foregroundColor = mentionColor\n                        // Emit '@' (non-localizable symbol - use interpolation to avoid extraction)\n                        let at = \"@\"\n                        result.append(AttributedString(\"\\(at)\").mergingAttributes(mentionStyle))\n                        // Base name\n                        result.append(AttributedString(mBase).mergingAttributes(mentionStyle))\n                        // Suffix in light grey\n                        if !mSuffix.isEmpty {\n                            var light = mentionStyle\n                            light.foregroundColor = mentionColor.opacity(0.6)\n                            result.append(AttributedString(mSuffix).mergingAttributes(light))\n                        }\n                    } else {\n                        // Style non-mention matches\n                        if type == \"hashtag\" {\n                            // If the hashtag is a valid geohash, make it tappable (bitchat://geohash/<gh>)\n                            let token = String(matchText.dropFirst()).lowercased()\n                            let allowed = Set(\"0123456789bcdefghjkmnpqrstuvwxyz\")\n                            let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }\n                            // Do not link if this hashtag is directly attached to an @mention (e.g., @name#geohash)\n                            let attachedToMention: Bool = {\n                                // nsRange is the Range<String.Index> for this match within content\n                                // Walk left until whitespace/newline; if we encounter '@' first, treat as part of mention\n                                if nsRange.lowerBound > content.startIndex {\n                                    var i = content.index(before: nsRange.lowerBound)\n                                    while true {\n                                        let ch = content[i]\n                                        if ch.isWhitespace || ch.isNewline { break }\n                                        if ch == \"@\" { return true }\n                                        if i == content.startIndex { break }\n                                        i = content.index(before: i)\n                                    }\n                                }\n                                return false\n                            }()\n                            // Also require the '#' to start a new token (whitespace or start-of-line before '#')\n                            let standalone: Bool = {\n                                if nsRange.lowerBound == content.startIndex { return true }\n                                let prev = content.index(before: nsRange.lowerBound)\n                                return content[prev].isWhitespace || content[prev].isNewline\n                            }()\n                            var tagStyle = AttributeContainer()\n                            tagStyle.font = isSelf\n                                ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)\n                                : .bitchatSystem(size: 14, design: .monospaced)\n                            tagStyle.foregroundColor = baseColor\n                            if isGeohash && !attachedToMention && standalone, let url = URL(string: \"bitchat://geohash/\\(token)\") {\n                                tagStyle.link = url\n                                tagStyle.underlineStyle = .single\n                            }\n                            result.append(AttributedString(matchText).mergingAttributes(tagStyle))\n                        } else if type == \"cashu\" {\n                            // Skip inline token; a styled chip is rendered below the message\n                            // We insert a single space to avoid words sticking together\n                            var spacer = AttributeContainer()\n                            spacer.foregroundColor = baseColor\n                            spacer.font = isSelf\n                                ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)\n                                : .bitchatSystem(size: 14, design: .monospaced)\n                            result.append(AttributedString(\" \").mergingAttributes(spacer))\n                        } else if type == \"lightning\" || type == \"bolt11\" || type == \"lnurl\" {\n                            // Skip inline invoice/link; a styled chip is rendered below the message\n                            var spacer = AttributeContainer()\n                            spacer.foregroundColor = baseColor\n                            spacer.font = isSelf\n                                ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)\n                                : .bitchatSystem(size: 14, design: .monospaced)\n                            result.append(AttributedString(\" \").mergingAttributes(spacer))\n                        } else {\n                            // Keep URL styling and make it tappable via .link attribute\n                            var matchStyle = AttributeContainer()\n                            matchStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced)\n                            if type == \"url\" {\n                                matchStyle.foregroundColor = isSelf ? .orange : .blue\n                                matchStyle.underlineStyle = .single\n                                if let url = URL(string: matchText) {\n                                    matchStyle.link = url\n                                }\n                            }\n                            result.append(AttributedString(matchText).mergingAttributes(matchStyle))\n                        }\n                    }\n                    // Advance lastEnd safely in case of overlaps\n                    if lastEnd < nsRange.upperBound {\n                        lastEnd = nsRange.upperBound\n                    }\n                }\n            }\n            \n            // Add remaining text\n            if lastEnd < content.endIndex {\n                let remainingText = String(content[lastEnd...])\n                var remainingStyle = AttributeContainer()\n                remainingStyle.foregroundColor = baseColor\n                remainingStyle.font = isSelf\n                    ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)\n                    : .bitchatSystem(size: 14, design: .monospaced)\n                if isMentioned {\n                    remainingStyle.font = remainingStyle.font?.bold()\n                }\n                result.append(AttributedString(remainingText).mergingAttributes(remainingStyle))\n            }\n            }\n            \n            // Add timestamp at the end (smaller, light grey)\n            let timestamp = AttributedString(\" [\\(message.formattedTimestamp)]\")\n            var timestampStyle = AttributeContainer()\n            timestampStyle.foregroundColor = Color.gray.opacity(0.7)\n            timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)\n            result.append(timestamp.mergingAttributes(timestampStyle))\n        } else {\n            // System message\n            var contentStyle = AttributeContainer()\n            contentStyle.foregroundColor = Color.gray\n            let content = AttributedString(\"* \\(message.content) *\")\n            contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()\n            result.append(content.mergingAttributes(contentStyle))\n            \n            // Add timestamp at the end for system messages too\n            let timestamp = AttributedString(\" [\\(message.formattedTimestamp)]\")\n            var timestampStyle = AttributeContainer()\n            timestampStyle.foregroundColor = Color.gray.opacity(0.5)\n            timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)\n            result.append(timestamp.mergingAttributes(timestampStyle))\n        }\n        \n        // Cache the formatted text\n        message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)\n        \n        return result\n    }\n\n    @MainActor\n    func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {\n        let isSelf: Bool = {\n            if let spid = message.senderPeerID {\n                if case .location(let ch) = activeChannel, spid.id.hasPrefix(\"nostr:\") {\n                    if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {\n                        return spid == PeerID(nostr: myGeo.publicKeyHex)\n                    }\n                }\n                return spid == meshService.myPeerID\n            }\n            if message.sender == nickname { return true }\n            if message.sender.hasPrefix(nickname + \"#\") { return true }\n            return false\n        }()\n\n        let isDark = colorScheme == .dark\n        let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)\n\n        if message.sender == \"system\" {\n            var style = AttributeContainer()\n            style.foregroundColor = baseColor\n            style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)\n            return AttributedString(message.sender).mergingAttributes(style)\n        }\n\n        var result = AttributedString()\n        let (baseName, suffix) = message.sender.splitSuffix()\n        var senderStyle = AttributeContainer()\n        senderStyle.foregroundColor = baseColor\n        senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)\n        if let spid = message.senderPeerID,\n           let url = URL(string: \"bitchat://user/\\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)\") {\n            senderStyle.link = url\n        }\n\n        result.append(AttributedString(\"<@\").mergingAttributes(senderStyle))\n        result.append(AttributedString(baseName).mergingAttributes(senderStyle))\n        if !suffix.isEmpty {\n            var suffixStyle = senderStyle\n            suffixStyle.foregroundColor = baseColor.opacity(0.6)\n            result.append(AttributedString(suffix).mergingAttributes(suffixStyle))\n        }\n        result.append(AttributedString(\"> \").mergingAttributes(senderStyle))\n        return result\n    }\n\n    // MARK: - Noise Protocol Support\n    \n    @MainActor\n    func updateEncryptionStatusForPeers() {\n        for peerID in connectedPeers {\n            updateEncryptionStatusForPeer(peerID)\n        }\n    }\n    \n    @MainActor\n    private func updateEncryptionStatusForPeer(_ peerID: PeerID) {\n        let noiseService = meshService.getNoiseService()\n        \n        if noiseService.hasEstablishedSession(with: peerID) {\n            peerEncryptionStatus[peerID] = encryptionStatus(for: peerID)\n        } else if noiseService.hasSession(with: peerID) {\n            // Session exists but not established - handshaking\n            peerEncryptionStatus[peerID] = .noiseHandshaking\n        } else {\n            // No session at all\n            peerEncryptionStatus[peerID] = Optional.none\n        }\n        \n        // Invalidate cache when encryption status changes\n        invalidateEncryptionCache(for: peerID)\n        \n        // UI will update automatically via @Published properties\n    }\n    \n    @MainActor\n    func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {\n        // Check cache first\n        if let cachedStatus = encryptionStatusCache[peerID] {\n            return cachedStatus\n        }\n        \n        // This must be a pure function - no state mutations allowed\n        // to avoid SwiftUI update loops\n        \n        // Check if we've ever established a session by looking for a fingerprint\n        let hasEverEstablishedSession = getFingerprint(for: peerID) != nil\n        \n        let sessionState = meshService.getNoiseSessionState(for: peerID)\n        \n        let status: EncryptionStatus\n        \n        // Determine status based on session state\n        switch sessionState {\n        case .established:\n            status = encryptionStatus(for: peerID)\n        case .handshaking, .handshakeQueued:\n            // If we've ever established a session, show secured instead of handshaking\n            if hasEverEstablishedSession {\n                // Check if it was verified before\n                status = encryptionStatus(for: peerID)\n            } else {\n                // First time establishing - show handshaking\n                status = .noiseHandshaking\n            }\n        case .none:\n            // If we've ever established a session, show secured instead of no handshake\n            if hasEverEstablishedSession {\n                // Check if it was verified before\n                status = encryptionStatus(for: peerID)\n            } else {\n                // Never established - show no handshake\n                status = .noHandshake\n            }\n        case .failed:\n            // If we've ever established a session, show secured instead of failed\n            if hasEverEstablishedSession {\n                // Check if it was verified before\n                status = encryptionStatus(for: peerID)\n            } else {\n                // Never established - show failed\n                status = .none\n            }\n        }\n        \n        // Cache the result\n        encryptionStatusCache[peerID] = status\n        \n        // Encryption status determined: \\(status)\n        \n        return status\n    }\n    \n    // Clear caches when data changes\n    private func invalidateEncryptionCache(for peerID: PeerID? = nil) {\n        if let peerID {\n            encryptionStatusCache.removeValue(forKey: peerID)\n        } else {\n            encryptionStatusCache.removeAll()\n        }\n    }\n    \n    \n    // MARK: - Message Handling\n    \n    func trimMessagesIfNeeded() {\n        if messages.count > maxMessages {\n            messages = Array(messages.suffix(maxMessages))\n        }\n    }\n\n    @MainActor\n    func refreshVisibleMessages(from channel: ChannelID? = nil) {\n        let target = channel ?? activeChannel\n        messages = timelineStore.messages(for: target)\n    }\n\n    @MainActor\n    private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {\n        if let spid = message.senderPeerID {\n            if spid.isGeoChat || spid.isGeoDM {\n                let full = nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased()\n                return getNostrPaletteColor(for: full, isDark: isDark)\n            } else if spid.id.count == 16 {\n                // Mesh short ID\n                return getPeerPaletteColor(for: spid, isDark: isDark)\n            } else {\n                return getPeerPaletteColor(for: PeerID(str: spid.id.lowercased()), isDark: isDark)\n            }\n        }\n        // Fallback when we only have a display name\n        return Color(peerSeed: message.sender.lowercased(), isDark: isDark)\n    }\n\n    // MARK: - MessageFormattingContext Protocol\n\n    @MainActor\n    func isSelfMessage(_ message: BitchatMessage) -> Bool {\n        if let spid = message.senderPeerID {\n            // In geohash channels, compare against our per-geohash nostr short ID\n            if case .location(let ch) = activeChannel, spid.isGeoChat {\n                let myGeo: NostrIdentity? = {\n                    if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash {\n                        return cached.identity\n                    }\n                    // Derive and cache\n                    if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {\n                        cachedGeohashIdentity = (ch.geohash, identity)\n                        return identity\n                    }\n                    return nil\n                }()\n                if let myGeo {\n                    return spid == PeerID(nostr: myGeo.publicKeyHex)\n                }\n            }\n            return spid == meshService.myPeerID\n        }\n        // Fallback by nickname\n        if message.sender == nickname { return true }\n        if message.sender.hasPrefix(nickname + \"#\") { return true }\n        return false\n    }\n\n    @MainActor\n    func senderColor(for message: BitchatMessage, isDark: Bool) -> Color {\n        return peerColor(for: message, isDark: isDark)\n    }\n\n    @MainActor\n    func peerURL(for peerID: PeerID) -> URL? {\n        return URL(string: \"bitchat://user/\\(peerID.toPercentEncoded())\")\n    }\n\n    // Public helpers for views to color peers consistently in lists\n    @MainActor\n    func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color {\n        return getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark)\n    }\n\n    @MainActor\n    func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {\n        return getPeerPaletteColor(for: peerID, isDark: isDark)\n    }\n\n    // MARK: - Peer Palette Coordination\n    private let meshPalette = MinimalDistancePalette(config: .mesh)\n    private let nostrPalette = MinimalDistancePalette(config: .nostr)\n\n    @MainActor\n    private func meshSeed(for peerID: PeerID) -> String {\n        if let full = getNoiseKeyForShortID(peerID)?.id.lowercased() {\n            return \"noise:\" + full\n        }\n        return peerID.id.lowercased()\n    }\n\n    @MainActor\n    private func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color {\n        if peerID == meshService.myPeerID {\n            return .orange\n        }\n\n        meshPalette.ensurePalette(for: currentMeshPaletteSeeds())\n        if let color = meshPalette.color(for: peerID.id, isDark: isDark) {\n            return color\n        }\n        return Color(peerSeed: meshSeed(for: peerID), isDark: isDark)\n    }\n\n    @MainActor\n    private func currentMeshPaletteSeeds() -> [String: String] {\n        let myID = meshService.myPeerID\n        var seeds: [String: String] = [:]\n        for peer in allPeers where peer.peerID != myID {\n            seeds[peer.peerID.id] = meshSeed(for: peer.peerID)\n        }\n        return seeds\n    }\n\n    @MainActor\n    private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {\n        let myHex = currentGeohashIdentityHex()\n        if let myHex, pubkeyHexLowercased == myHex {\n            return .orange\n        }\n\n        nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex))\n        if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) {\n            return color\n        }\n        return Color(peerSeed: \"nostr:\" + pubkeyHexLowercased, isDark: isDark)\n    }\n\n    @MainActor\n    private func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] {\n        var seeds: [String: String] = [:]\n        let excluded = myHex ?? \"\"\n        for person in visibleGeohashPeople() where person.id != excluded {\n            seeds[person.id] = \"nostr:\" + person.id\n        }\n        return seeds\n    }\n\n    @MainActor\n    private func currentGeohashIdentityHex() -> String? {\n        if case .location(let channel) = LocationChannelManager.shared.selectedChannel,\n           let identity = try? idBridge.deriveIdentity(forGeohash: channel.geohash) {\n            return identity.publicKeyHex.lowercased()\n        }\n        return nil\n    }\n\n    // Clear the current public channel's timeline (visible + persistent buffer)\n    @MainActor\n    func clearCurrentPublicTimeline() {\n        // Clear messages from current timeline\n        messages.removeAll()\n        timelineStore.clear(channel: activeChannel)\n\n        // Delete associated media files (images, voice notes, files) in background\n        // Only delete from current chat to avoid removing private chat media\n        Task.detached(priority: .utility) {\n            do {\n                let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)\n                let filesDir = base.appendingPathComponent(\"files\", isDirectory: true)\n\n                // Only clear public media (mesh channel only - geohash media is separate)\n                // Note: This is conservative - only clears outgoing since we authored those\n                let outgoingDirs = [\n                    filesDir.appendingPathComponent(\"voicenotes/outgoing\", isDirectory: true),\n                    filesDir.appendingPathComponent(\"images/outgoing\", isDirectory: true),\n                    filesDir.appendingPathComponent(\"files/outgoing\", isDirectory: true)\n                ]\n\n                for dir in outgoingDirs {\n                    if FileManager.default.fileExists(atPath: dir.path) {\n                        try? FileManager.default.removeItem(at: dir)\n                        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)\n                    }\n                }\n            } catch {\n                SecureLogger.error(\"Failed to clear media files: \\(error)\", category: .session)\n            }\n        }\n    }\n    \n    // MARK: - Message Management\n    \n    private func addMessage(_ message: BitchatMessage) {\n        // Check for duplicates\n        guard !messages.contains(where: { $0.id == message.id }) else { return }\n        messages.append(message)\n        trimMessagesIfNeeded()\n    }\n    \n    // Update encryption status in appropriate places, not during view updates\n    @MainActor\n    private func updateEncryptionStatus(for peerID: PeerID) {\n        let noiseService = meshService.getNoiseService()\n        \n        if noiseService.hasEstablishedSession(with: peerID) {\n            peerEncryptionStatus[peerID] = encryptionStatus(for: peerID)\n        } else if noiseService.hasSession(with: peerID) {\n            peerEncryptionStatus[peerID] = .noiseHandshaking\n        } else {\n            peerEncryptionStatus[peerID] = Optional.none\n        }\n        \n        // Invalidate cache when encryption status changes\n        invalidateEncryptionCache(for: peerID)\n        \n        // UI will update automatically via @Published properties\n    }\n    \n    // MARK: - Fingerprint Management\n    \n    func showFingerprint(for peerID: PeerID) {\n        showingFingerprintFor = peerID\n    }\n    \n    // MARK: - Peer Lookup Helpers\n    \n    func getPeer(byID peerID: PeerID) -> BitchatPeer? {\n        return peerIndex[peerID]\n    }\n    \n    @MainActor\n    func getFingerprint(for peerID: PeerID) -> String? {\n        return unifiedPeerService.getFingerprint(for: peerID)\n    }\n    \n    /// Check if fingerprint is verified using our persisted data\n    @MainActor\n    private func encryptionStatus(for peerID: PeerID) -> EncryptionStatus {\n        if let fp = getFingerprint(for: peerID), verifiedFingerprints.contains(fp) {\n            return .noiseVerified\n        } else {\n            return .noiseSecured\n        }\n    }\n    \n    /// Helper to resolve nickname for a peer ID through various sources\n    @MainActor\n    private func resolveNickname(for peerID: PeerID) -> String {\n        // Guard against empty or very short peer IDs\n        guard !peerID.isEmpty else {\n            return \"unknown\"\n        }\n        \n        // Check if this might already be a nickname (not a hex peer ID)\n        // Peer IDs are hex strings, so they only contain 0-9 and a-f\n        if !peerID.isHex {\n            // If it's already a nickname, just return it\n            return peerID.id\n        }\n        \n        // First try direct peer nicknames from mesh service\n        let peerNicknames = meshService.getPeerNicknames()\n        if let nickname = peerNicknames[peerID] {\n            return nickname\n        }\n        \n        // Try to resolve through fingerprint and social identity\n        if let fingerprint = getFingerprint(for: peerID) {\n            if let identity = identityManager.getSocialIdentity(for: fingerprint) {\n                // Prefer local petname if set\n                if let petname = identity.localPetname {\n                    return petname\n                }\n                // Otherwise use their claimed nickname\n                return identity.claimedNickname\n            }\n        }\n        \n        // Use anonymous with shortened peer ID\n        // Ensure we have at least 4 characters for the prefix\n        let prefixLength = min(4, peerID.id.count)\n        let prefix = String(peerID.id.prefix(prefixLength))\n        \n        // Avoid \"anonanon\" by checking if ID already starts with \"anon\"\n        if prefix.starts(with: \"anon\") {\n            return \"peer\\(prefix)\"\n        }\n        return \"anon\\(prefix)\"\n    }\n    \n    func getMyFingerprint() -> String {\n        let fingerprint = meshService.getNoiseService().getIdentityFingerprint()\n        return fingerprint\n    }\n    \n    @MainActor\n    func verifyFingerprint(for peerID: PeerID) {\n        guard let fingerprint = getFingerprint(for: peerID) else { return }\n        \n        // Update secure storage with verified status\n        identityManager.setVerified(fingerprint: fingerprint, verified: true)\n        saveIdentityState()\n        \n        // Update local set for UI\n        verifiedFingerprints.insert(fingerprint)\n        \n        // Update encryption status after verification\n        updateEncryptionStatus(for: peerID)\n    }\n\n    @MainActor\n    func unverifyFingerprint(for peerID: PeerID) {\n        guard let fingerprint = getFingerprint(for: peerID) else { return }\n        identityManager.setVerified(fingerprint: fingerprint, verified: false)\n        saveIdentityState()\n        verifiedFingerprints.remove(fingerprint)\n        updateEncryptionStatus(for: peerID)\n    }\n    \n    @MainActor\n    func loadVerifiedFingerprints() {\n        // Load verified fingerprints directly from secure storage\n        verifiedFingerprints = identityManager.getVerifiedFingerprints()\n        // Log snapshot for debugging persistence\n        let sample = Array(verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: \", \")\n        SecureLogger.info(\"🔐 Verified loaded: \\(verifiedFingerprints.count) [\\(sample)]\", category: .security)\n        // Also log any offline favorites and whether we consider them verified\n        let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected }\n        for fav in offlineFavorites {\n            let fp = unifiedPeerService.getFingerprint(for: fav.peerID)\n            let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false\n            let fpShort = fp?.prefix(8) ?? \"nil\"\n            SecureLogger.info(\"⭐️ Favorite offline: \\(fav.nickname) fp=\\(fpShort) verified=\\(isVer)\", category: .security)\n        }\n        // Invalidate cached encryption statuses so offline favorites can show verified badges immediately\n        invalidateEncryptionCache()\n        // Trigger UI refresh of peer list\n        objectWillChange.send()\n    }\n    \n    private func setupNoiseCallbacks() {\n        let noiseService = meshService.getNoiseService()\n        \n        // Set up authentication callback\n        noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in\n            DispatchQueue.main.async {\n                guard let self = self else { return }\n\n                SecureLogger.debug(\"🔐 Authenticated: \\(peerID)\", category: .security)\n\n                // Update encryption status\n                if self.verifiedFingerprints.contains(fingerprint) {\n                    self.peerEncryptionStatus[peerID] = .noiseVerified\n                    // Encryption: noiseVerified\n                } else {\n                    self.peerEncryptionStatus[peerID] = .noiseSecured\n                    // Encryption: noiseSecured\n                }\n\n                // Invalidate cache when encryption status changes\n                self.invalidateEncryptionCache(for: peerID)\n\n                // Cache shortID -> full Noise key mapping as soon as session authenticates\n                if self.shortIDToNoiseKey[peerID] == nil,\n                   let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {\n                    let stable = PeerID(hexData: keyData)\n                    self.shortIDToNoiseKey[peerID] = stable\n                    SecureLogger.debug(\"🗺️ Mapped short peerID to Noise key for header continuity: \\(peerID) -> \\(stable.id.prefix(8))…\", category: .session)\n                }\n\n                // If a QR verification is pending but not sent yet, send it now that session is authenticated\n                if var pending = self.pendingQRVerifications[peerID], pending.sent == false {\n                    self.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: pending.noiseKeyHex, nonceA: pending.nonceA)\n                    pending.sent = true\n                    self.pendingQRVerifications[peerID] = pending\n                    SecureLogger.debug(\"📤 Sent deferred verify challenge to \\(peerID) after handshake\", category: .security)\n                }\n\n                // Schedule UI update\n                // UI will update automatically\n            }\n        }\n        \n        // Set up handshake required callback\n        noiseService.onHandshakeRequired = { [weak self] peerID in\n            DispatchQueue.main.async {\n                guard let self = self else { return }\n                self.peerEncryptionStatus[peerID] = .noiseHandshaking\n                \n                // Invalidate cache when encryption status changes\n                self.invalidateEncryptionCache(for: peerID)\n            }\n        }\n    }\n    \n    // MARK: - BitchatDelegate Methods\n    \n    // MARK: - Command Handling\n    \n    /// Processes IRC-style commands starting with '/'.\n    /// - Parameter command: The full command string including the leading slash\n    /// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help\n    @MainActor\n    private func handleCommand(_ command: String) {\n        let result = commandProcessor.process(command)\n        \n        switch result {\n        case .success(let message):\n            if let msg = message {\n                addSystemMessage(msg)\n            }\n        case .error(let message):\n            addSystemMessage(message)\n        case .handled:\n            // Command was handled, no message needed\n            break\n        }\n    }\n    \n    // MARK: - Message Reception\n    \n    func didReceiveMessage(_ message: BitchatMessage) {\n        Task { @MainActor in\n            // Early validation\n            guard !isMessageBlocked(message) else { return }\n            guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return }\n            \n            // Route to appropriate handler\n            if message.isPrivate {\n                handlePrivateMessage(message)\n            } else {\n                handlePublicMessage(message)\n            }\n            \n            // Post-processing\n            checkForMentions(message)\n            sendHapticFeedback(for: message)\n        }\n    }\n\n    /// Find message index trying both short (16-hex) and long (64-hex) peer ID formats.\n    /// Returns the peer ID where the message was found and its index, or nil if not found.\n    private func findMessageIndex(messageID: String, peerID: PeerID) -> (peerID: PeerID, index: Int)? {\n        // Try direct lookup first\n        if let messages = privateChats[peerID],\n           let idx = messages.firstIndex(where: { $0.id == messageID }) {\n            return (peerID, idx)\n        }\n\n        // Try with full noise key if peerID is short (16 hex chars)\n        if peerID.bare.count == 16,\n           let peer = unifiedPeerService.getPeer(by: peerID),\n           !peer.noisePublicKey.isEmpty {\n            let longID = PeerID(hexData: peer.noisePublicKey)\n            if let messages = privateChats[longID],\n               let idx = messages.firstIndex(where: { $0.id == messageID }) {\n                return (longID, idx)\n            }\n        }\n\n        // Try with short form if peerID is long (64 hex = noise key)\n        if peerID.bare.count == 64 {\n            let shortID = peerID.toShort()\n            if let messages = privateChats[shortID],\n               let idx = messages.firstIndex(where: { $0.id == messageID }) {\n                return (shortID, idx)\n            }\n        }\n\n        return nil\n    }\n\n    // Low-level BLE events\n    func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {\n        Task { @MainActor in\n            switch type {\n            case .privateMessage:\n                guard let pm = PrivateMessagePacket.decode(from: payload) else { return }\n\n                // BCH-01-012: Check blocking before processing private message to prevent notification bypass\n                if isPeerBlocked(peerID) {\n                    SecureLogger.debug(\"🚫 Ignoring Noise payload from blocked peer: \\(peerID)\", category: .security)\n                    return\n                }\n\n                let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? \"Unknown\"\n                let pmMentions = parseMentions(from: pm.content)\n                let msg = BitchatMessage(\n                    id: pm.messageID,\n                    sender: senderName,\n                    content: pm.content,\n                    timestamp: timestamp,\n                    isRelay: false,\n                    originalSender: nil,\n                    isPrivate: true,\n                    recipientNickname: nickname,\n                    senderPeerID: peerID,\n                    mentions: pmMentions.isEmpty ? nil : pmMentions\n                )\n                handlePrivateMessage(msg)\n                // Send delivery ACK back over BLE\n                meshService.sendDeliveryAck(for: pm.messageID, to: peerID)\n\n            case .delivered:\n                guard let messageID = String(data: payload, encoding: .utf8) else { return }\n                guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname,\n                      let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return }\n\n                // Don't downgrade from .read to .delivered\n                if case .read = privateChats[foundPeerID]?[idx].deliveryStatus { return }\n\n                privateChats[foundPeerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())\n                objectWillChange.send()\n\n            case .readReceipt:\n                guard let messageID = String(data: payload, encoding: .utf8) else { return }\n                guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname,\n                      let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return }\n\n                // Explicitly unwrap and re-assign to ensure the @Published setter is called\n                if let messages = privateChats[foundPeerID], idx < messages.count {\n                    messages[idx].deliveryStatus = .read(by: name, at: Date())\n                    privateChats[foundPeerID] = messages\n                    privateChatManager.objectWillChange.send()\n                    objectWillChange.send()\n                }\n            case .verifyChallenge:\n                // Parse and respond\n                guard let tlv = VerificationService.shared.parseVerifyChallenge(payload) else { return }\n                // Ensure intended for our noise key\n                let myNoiseHex = meshService.getNoiseService().getStaticPublicKeyData().hexEncodedString().lowercased()\n                guard tlv.noiseKeyHex.lowercased() == myNoiseHex else { return }\n                // Deduplicate: ignore if we've already responded to this nonce for this peer\n                if let last = lastVerifyNonceByPeer[peerID], last == tlv.nonceA { return }\n                lastVerifyNonceByPeer[peerID] = tlv.nonceA\n                // Record inbound challenge time keyed by stable fingerprint if available\n                if let fp = getFingerprint(for: peerID) {\n                    lastInboundVerifyChallengeAt[fp] = Date()\n                    // If we've already verified this fingerprint locally, treat this as mutual and toast immediately (responder side)\n                    if verifiedFingerprints.contains(fp) {\n                        let now = Date()\n                        let last = lastMutualToastAt[fp] ?? .distantPast\n                        if now.timeIntervalSince(last) > 60 { // 1-minute throttle\n                            lastMutualToastAt[fp] = now\n                            let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)\n                            NotificationService.shared.sendLocalNotification(\n                                title: \"Mutual verification\",\n                                body: \"You and \\(name) verified each other\",\n                                identifier: \"verify-mutual-\\(peerID)-\\(UUID().uuidString)\"\n                            )\n                        }\n                    }\n                }\n                meshService.sendVerifyResponse(to: peerID, noiseKeyHex: tlv.noiseKeyHex, nonceA: tlv.nonceA)\n                // Silent response: no toast needed on responder\n            case .verifyResponse:\n                guard let resp = VerificationService.shared.parseVerifyResponse(payload) else { return }\n                // Check pending for this peer\n                guard let pending = pendingQRVerifications[peerID] else { return }\n                guard resp.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), resp.nonceA == pending.nonceA else { return }\n                // Verify signature with expected sign key\n                let ok = VerificationService.shared.verifyResponseSignature(noiseKeyHex: resp.noiseKeyHex, nonceA: resp.nonceA, signature: resp.signature, signerPublicKeyHex: pending.signKeyHex)\n                if ok {\n                    pendingQRVerifications.removeValue(forKey: peerID)\n                    if let fp = getFingerprint(for: peerID) {\n                        let short = fp.prefix(8)\n                        SecureLogger.info(\"🔐 Marking verified fingerprint: \\(short)\", category: .security)\n                        identityManager.setVerified(fingerprint: fp, verified: true)\n                        saveIdentityState()\n                        verifiedFingerprints.insert(fp)\n                        let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)\n                        NotificationService.shared.sendLocalNotification(\n                            title: \"Verified\",\n                            body: \"You verified \\(name)\",\n                            identifier: \"verify-success-\\(peerID)-\\(UUID().uuidString)\"\n                        )\n                        // If we also recently responded to their challenge, flag mutual and toast (initiator side)\n                        if let t = lastInboundVerifyChallengeAt[fp], Date().timeIntervalSince(t) < 600 {\n                            let now = Date()\n                            let lastToast = lastMutualToastAt[fp] ?? .distantPast\n                            if now.timeIntervalSince(lastToast) > 60 {\n                                lastMutualToastAt[fp] = now\n                                NotificationService.shared.sendLocalNotification(\n                                    title: \"Mutual verification\",\n                                    body: \"You and \\(name) verified each other\",\n                                    identifier: \"verify-mutual-\\(peerID)-\\(UUID().uuidString)\"\n                                )\n                            }\n                        }\n                        updateEncryptionStatus(for: peerID)\n                    }\n                }\n            }\n        }\n    }\n\n    func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {\n        Task { @MainActor in\n            let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)\n            let publicMentions = parseMentions(from: normalized)\n            let msg = BitchatMessage(\n                id: messageID,\n                sender: nickname,\n                content: normalized,\n                timestamp: timestamp,\n                isRelay: false,\n                originalSender: nil,\n                isPrivate: false,\n                recipientNickname: nil,\n                senderPeerID: peerID,\n                mentions: publicMentions.isEmpty ? nil : publicMentions\n            )\n            handlePublicMessage(msg)\n            checkForMentions(msg)\n            sendHapticFeedback(for: msg)\n        }\n    }\n\n    // MARK: - QR Verification API\n    @MainActor\n    func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {\n        // Find a matching peer by Noise key\n        let targetNoise = qr.noiseKeyHex.lowercased()\n        guard let peer = unifiedPeerService.peers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else {\n            return false\n        }\n        let peerID = peer.peerID\n        // If we already have a pending verification with this peer, don't send another\n        if pendingQRVerifications[peerID] != nil {\n            return true\n        }\n        // Generate nonceA\n        var nonce = Data(count: 16)\n        _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }\n        var pending = PendingVerification(noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, nonceA: nonce, startedAt: Date(), sent: false)\n        pendingQRVerifications[peerID] = pending\n        // If Noise session is established, send immediately; otherwise trigger handshake and send on auth\n        let noise = meshService.getNoiseService()\n        if noise.hasEstablishedSession(with: peerID) {\n            meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)\n            pending.sent = true\n            pendingQRVerifications[peerID] = pending\n        } else {\n            meshService.triggerHandshake(with: peerID)\n        }\n        return true\n    }\n\n    // Mention parsing moved from BLE – use the existing non-optional helper below\n    // MARK: - Bluetooth State Monitoring\n\n    func didUpdateBluetoothState(_ state: CBManagerState) {\n        Task { @MainActor in\n            updateBluetoothState(state)\n        }\n    }\n\n    // MARK: - Peer Connection Events\n\n    func didConnectToPeer(_ peerID: PeerID) {\n        SecureLogger.debug(\"🤝 Peer connected: \\(peerID)\", category: .session)\n        \n        // Handle all main actor work async\n        Task { @MainActor in\n            isConnected = true\n            \n            // Register ephemeral session with identity manager\n            identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)\n            \n            // Intentionally do not resend favorites on reconnect.\n            // We only send our npub when a favorite is toggled on, or if our npub changes.\n            \n            // Force UI refresh\n            objectWillChange.send()\n\n            // Cache mapping to full Noise key for session continuity on disconnect\n            if let peer = unifiedPeerService.getPeer(by: peerID) {\n                let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)\n                shortIDToNoiseKey[peerID] = noiseKeyHex\n            }\n\n            // Flush any queued messages for this peer via router\n            messageRouter.flushOutbox(for: peerID)\n        }\n    }\n    \n    func didDisconnectFromPeer(_ peerID: PeerID) {\n        SecureLogger.debug(\"👋 Peer disconnected: \\(peerID)\", category: .session)\n        \n        // Remove ephemeral session from identity manager\n        identityManager.removeEphemeralSession(peerID: peerID)\n\n        // If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite)\n        var derivedStableKeyHex = shortIDToNoiseKey[peerID]\n        if derivedStableKeyHex == nil,\n           let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {\n            derivedStableKeyHex = PeerID(hexData: key)\n            shortIDToNoiseKey[peerID] = derivedStableKeyHex\n        }\n\n        if let current = selectedPrivateChatPeer, current == peerID, let stableKeyHex = derivedStableKeyHex {\n            // Migrate messages view context to stable key so header shows favorite + Nostr globe\n            if let messages = privateChats[peerID] {\n                if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }\n                let existing = Set(privateChats[stableKeyHex]!.map { $0.id })\n                for msg in messages where !existing.contains(msg.id) {\n                    let updated = BitchatMessage(\n                        id: msg.id,\n                        sender: msg.sender,\n                        content: msg.content,\n                        timestamp: msg.timestamp,\n                        isRelay: msg.isRelay,\n                        originalSender: msg.originalSender,\n                        isPrivate: msg.isPrivate,\n                        recipientNickname: msg.recipientNickname,\n                        senderPeerID: msg.senderPeerID == meshService.myPeerID ? meshService.myPeerID : stableKeyHex,\n                        mentions: msg.mentions,\n                        deliveryStatus: msg.deliveryStatus\n                    )\n                    privateChats[stableKeyHex]?.append(updated)\n                }\n                privateChats[stableKeyHex]?.sort { $0.timestamp < $1.timestamp }\n                privateChats.removeValue(forKey: peerID)\n            }\n            if unreadPrivateMessages.contains(peerID) {\n                unreadPrivateMessages.remove(peerID)\n                unreadPrivateMessages.insert(stableKeyHex)\n            }\n            selectedPrivateChatPeer = stableKeyHex\n            objectWillChange.send()\n        }\n        \n        // Update peer list immediately and force UI refresh\n        DispatchQueue.main.async { [weak self] in\n            // UnifiedPeerService updates automatically via subscriptions\n            self?.objectWillChange.send()\n        }\n        \n        // Clear sent read receipts for this peer since they'll need to be resent after reconnection\n        // Only clear receipts for messages from this specific peer\n        if let messages = privateChats[peerID] {\n            for message in messages {\n                // Remove read receipts for messages FROM this peer (not TO this peer)\n                if message.senderPeerID == peerID {\n                    sentReadReceipts.remove(message.id)\n                }\n            }\n        }\n    }\n    \n    func didUpdatePeerList(_ peers: [PeerID]) {\n        // UI updates must run on the main thread.\n        // The delegate callback is not guaranteed to be on the main thread.\n        DispatchQueue.main.async {\n            // Update through peer manager\n            // UnifiedPeerService updates automatically via subscriptions\n            self.isConnected = !peers.isEmpty\n            \n            // Clean up stale unread peer IDs whenever peer list updates\n            self.cleanupStaleUnreadPeerIDs()\n            \n            // Smart notification logic for \"bitchatters nearby\"\n            let meshPeers = peers.filter { peerID in\n                self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)\n            }\n            let meshPeerSet = Set(meshPeers)\n            \n            if meshPeerSet.isEmpty {\n                self.scheduleNetworkEmptyTimer()\n            } else {\n                self.invalidateNetworkEmptyTimer()\n                // Don't trim recentlySeenPeers here - let timers handle cleanup.\n                // Trimming immediately causes peers to be treated as \"new\" when they\n                // briefly drop and reconnect, triggering notification floods.\n                let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)\n\n                if !newPeers.isEmpty {\n                    // Rate limit: max one notification per 5 minutes\n                    let cooldown = TransportConfig.networkNotificationCooldownSeconds\n                    if Date().timeIntervalSince(self.lastNetworkNotificationTime) >= cooldown {\n                        // Only mark peers as seen when we actually notify about them\n                        // This ensures peers arriving during cooldown will be included in the next notification\n                        self.recentlySeenPeers.formUnion(newPeers)\n                        self.lastNetworkNotificationTime = Date()\n                        NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)\n                        SecureLogger.info(\n                            \"👥 Sent bitchatters nearby notification for \\(meshPeers.count) mesh peers (new: \\(newPeers.count))\",\n                            category: .session\n                        )\n                    }\n                    self.scheduleNetworkResetTimer()\n                }\n            }\n            \n            // Register ephemeral sessions for all connected peers\n            for peerID in peers {\n                self.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)\n            }\n            \n            // Schedule UI refresh to ensure offline favorites are shown\n            // UI will update automatically\n            \n            // Update encryption status for all peers\n            self.updateEncryptionStatusForPeers()\n\n            // Schedule UI update for peer list change\n            // UI will update automatically\n            \n            // Check if we need to update private chat peer after reconnection\n            if self.selectedPrivateChatFingerprint != nil {\n                self.updatePrivateChatPeerIfNeeded()\n            }\n            \n            // Don't end private chat when peer temporarily disconnects\n            // The fingerprint tracking will allow us to reconnect when they come back\n        }\n    }\n    \n    // MARK: - Helper Methods\n    \n    /// Clean up stale unread peer IDs that no longer exist in the peer list\n    @MainActor\n    private func cleanupStaleUnreadPeerIDs() {\n        let currentPeerIDs = Set(unifiedPeerService.peers.map { $0.peerID })\n        let staleIDs = unreadPrivateMessages.subtracting(currentPeerIDs)\n        \n        if !staleIDs.isEmpty {\n            var idsToRemove: [PeerID] = []\n            for staleID in staleIDs {\n                // Don't remove temporary Nostr peer IDs that have messages\n                if staleID.isGeoDM {\n                    // Check if we have messages from this temporary peer\n                    if let messages = privateChats[staleID], !messages.isEmpty {\n                        // Keep this ID - it has messages\n                        continue\n                    }\n                }\n                \n                // Don't remove stable Noise key hexes (64 char hex strings) that have messages\n                // These are used for Nostr messages when peer is offline\n                if staleID.isNoiseKeyHex {\n                    if let messages = privateChats[staleID], !messages.isEmpty {\n                        // Keep this ID - it's a stable key with messages\n                        continue\n                    }\n                }\n                \n                // Remove this stale ID\n                idsToRemove.append(staleID)\n                unreadPrivateMessages.remove(staleID)\n            }\n            \n            if !idsToRemove.isEmpty {\n                SecureLogger.debug(\"🧹 Cleaned up \\(idsToRemove.count) stale unread peer IDs\", category: .session)\n            }\n        }\n        \n        // Also clean up old sentReadReceipts to prevent unlimited growth\n        // Keep only receipts from messages we still have\n        cleanupOldReadReceipts()\n    }\n\n    @MainActor\n    private func scheduleNetworkResetTimer() {\n        networkResetTimer?.invalidate()\n        networkResetTimer = Timer.scheduledTimer(\n            timeInterval: networkResetGraceSeconds,\n            target: self,\n            selector: #selector(onNetworkResetTimerFired(_:)),\n            userInfo: nil,\n            repeats: false\n        )\n    }\n\n    @MainActor\n    @objc private func onNetworkResetTimerFired(_ timer: Timer) {\n        let activeMeshPeers = meshService\n            .currentPeerSnapshots()\n            .filter { snapshot in\n                snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)\n            }\n        if activeMeshPeers.isEmpty {\n            recentlySeenPeers.removeAll()\n            SecureLogger.debug(\"⏱️ Network notification window reset after quiet period\", category: .session)\n        } else {\n            SecureLogger.debug(\"⏱️ Skipped network notification reset; still seeing \\(activeMeshPeers.count) mesh peers\", category: .session)\n        }\n        networkResetTimer = nil\n    }\n\n    @MainActor\n    private func scheduleNetworkEmptyTimer() {\n        guard networkEmptyTimer == nil else { return }\n        networkEmptyTimer = Timer.scheduledTimer(\n            timeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds,\n            target: self,\n            selector: #selector(onNetworkEmptyTimerFired(_:)),\n            userInfo: nil,\n            repeats: false\n        )\n        SecureLogger.debug(\"⏳ Mesh empty — waiting before resetting notification state\", category: .session)\n    }\n\n    @MainActor\n    private func invalidateNetworkEmptyTimer() {\n        if networkEmptyTimer != nil {\n            networkEmptyTimer?.invalidate()\n            networkEmptyTimer = nil\n        }\n    }\n\n    @MainActor\n    @objc private func onNetworkEmptyTimerFired(_ timer: Timer) {\n        let activeMeshPeers = meshService\n            .currentPeerSnapshots()\n            .filter { snapshot in\n                snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)\n            }\n        if activeMeshPeers.isEmpty {\n            recentlySeenPeers.removeAll()\n            SecureLogger.debug(\"⏳ Mesh empty — notification state reset after confirmation\", category: .session)\n        } else {\n            SecureLogger.debug(\"⏳ Mesh empty timer cancelled; \\(activeMeshPeers.count) mesh peers detected again\", category: .session)\n        }\n        networkEmptyTimer = nil\n    }\n    \n    private func cleanupOldReadReceipts() {\n        // Skip cleanup during startup phase or if privateChats is empty\n        // This prevents removing valid receipts before messages are loaded\n        if isStartupPhase || privateChats.isEmpty {\n            return\n        }\n        \n        // Build set of all message IDs we still have\n        var validMessageIDs = Set<String>()\n        for (_, messages) in privateChats {\n            for message in messages {\n                validMessageIDs.insert(message.id)\n            }\n        }\n        \n        // Remove receipts for messages we no longer have\n        let oldCount = sentReadReceipts.count\n        sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)\n        \n        let removedCount = oldCount - sentReadReceipts.count\n        if removedCount > 0 {\n            SecureLogger.debug(\"🧹 Cleaned up \\(removedCount) old read receipts\", category: .session)\n        }\n    }\n    \n    func parseMentions(from content: String) -> [String] {\n        // Allow optional disambiguation suffix '#abcd' for duplicate nicknames\n        let regex = Patterns.mention\n        let nsContent = content as NSString\n        let nsLen = nsContent.length\n        let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))\n        \n        var mentions: [String] = []\n        let peerNicknames = meshService.getPeerNicknames()\n        // Compose the valid mention tokens based on current peers (already suffixed where needed)\n        var validTokens = Set(peerNicknames.values)\n        // Always allow mentioning self by base nickname and suffixed disambiguator\n        validTokens.insert(nickname)\n        let selfSuffixToken = nickname + \"#\" + String(meshService.myPeerID.id.prefix(4))\n        validTokens.insert(selfSuffixToken)\n        \n        for match in matches {\n            if let range = Range(match.range(at: 1), in: content) {\n                let mentionedName = String(content[range])\n                // Only include if it's a current valid token (base or suffixed)\n                if validTokens.contains(mentionedName) {\n                    mentions.append(mentionedName)\n                }\n            }\n        }\n        \n        return Array(Set(mentions)) // Remove duplicates\n    }\n    \n    func isFavorite(fingerprint: String) -> Bool {\n        return identityManager.isFavorite(fingerprint: fingerprint)\n    }\n    \n    // MARK: - Delivery Tracking\n    \n    func didReceiveReadReceipt(_ receipt: ReadReceipt) {\n        // Find the message and update its read status\n        updateMessageDeliveryStatus(receipt.originalMessageID, status: .read(by: receipt.readerNickname, at: receipt.timestamp))\n    }\n    \n    func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {\n        updateMessageDeliveryStatus(messageID, status: status)\n    }\n    \n    func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {\n        \n        // Helper function to check if we should skip this update\n        func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {\n            guard let current = currentStatus else { return false }\n            \n            // Don't downgrade from read to delivered\n            switch (current, newStatus) {\n            case (.read, .delivered):\n                return true\n            case (.read, .sent):\n                return true\n            default:\n                return false\n            }\n        }\n        \n        // Update in main messages\n        if let index = messages.firstIndex(where: { $0.id == messageID }) {\n            let currentStatus = messages[index].deliveryStatus\n            if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {\n                messages[index].deliveryStatus = status\n            }\n        }\n        \n        // Update in private chats\n        for (peerID, chatMessages) in privateChats {\n            guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue }\n            \n            let currentStatus = chatMessages[index].deliveryStatus\n            guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }\n            \n            // Update delivery status directly (BitchatMessage is a class/reference type)\n            privateChats[peerID]?[index].deliveryStatus = status\n        }\n        \n        // Trigger UI update for delivery status change\n        DispatchQueue.main.async { [weak self] in\n            self?.objectWillChange.send()\n        }\n        \n    }\n    \n    // MARK: - Helper for System Messages\n    func addSystemMessage(_ content: String, timestamp: Date = Date()) {\n        let systemMessage = BitchatMessage(\n            sender: \"system\",\n            content: content,\n            timestamp: timestamp,\n            isRelay: false\n        )\n        messages.append(systemMessage)\n    }\n\n    /// Add a system message to the mesh timeline only (never geohash).\n    /// If mesh is currently active, also append to the visible `messages`.\n    @MainActor\n    func addMeshOnlySystemMessage(_ content: String) {\n        let systemMessage = BitchatMessage(\n            sender: \"system\",\n            content: content,\n            timestamp: Date(),\n            isRelay: false\n        )\n        timelineStore.append(systemMessage, to: .mesh)\n        refreshVisibleMessages()\n        trimMessagesIfNeeded()\n        objectWillChange.send()\n    }\n\n    /// Public helper to add a system message to the public chat timeline.\n    /// Also persists the message into the active channel's backing store so it survives timeline rebinds.\n    @MainActor\n    func addPublicSystemMessage(_ content: String) {\n        let systemMessage = BitchatMessage(\n            sender: \"system\",\n            content: content,\n            timestamp: Date(),\n            isRelay: false\n        )\n        timelineStore.append(systemMessage, to: activeChannel)\n        refreshVisibleMessages(from: activeChannel)\n        // Track the content key so relayed copies of the same system-style message are ignored\n        let contentKey = deduplicationService.normalizedContentKey(systemMessage.content)\n        deduplicationService.recordContentKey(contentKey, timestamp: systemMessage.timestamp)\n        trimMessagesIfNeeded()\n        objectWillChange.send()\n    }\n\n    /// Add a system message only if viewing a geohash location channel (never post to mesh).\n    @MainActor\n    func addGeohashOnlySystemMessage(_ content: String) {\n        if case .location = activeChannel {\n            addPublicSystemMessage(content)\n        } else {\n            // Not on a location channel yet: queue to show when user switches\n            timelineStore.queueGeohashSystemMessage(content)\n        }\n    }\n    // Send a public message without adding a local user echo.\n    // Used for emotes where we want a local system-style confirmation instead.\n    @MainActor\n    func sendPublicRaw(_ content: String) {\n        if case .location(let ch) = activeChannel {\n            Task { @MainActor in\n                do {\n                    let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)\n                    let event = try NostrProtocol.createEphemeralGeohashEvent(\n                        content: content,\n                        geohash: ch.geohash,\n                        senderIdentity: identity,\n                        nickname: self.nickname,\n                        teleported: LocationChannelManager.shared.teleported\n                    )\n                    let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)\n                    if targetRelays.isEmpty {\n                        NostrRelayManager.shared.sendEvent(event)\n                    } else {\n                        NostrRelayManager.shared.sendEvent(event, to: targetRelays)\n                    }\n                } catch {\n                    SecureLogger.error(\"❌ Failed to send geohash raw message: \\(error)\", category: .session)\n                }\n            }\n            return\n        }\n        // Default: send over mesh\n        meshService.sendMessage(content,\n                                mentions: [],\n                                messageID: UUID().uuidString,\n                                timestamp: Date())\n    }\n    \n\n    \n\n    \n\n    \n    // MARK: - Base64URL utils\n    static func base64URLDecode(_ s: String) -> Data? {\n        var str = s.replacingOccurrences(of: \"-\", with: \"+\")\n                    .replacingOccurrences(of: \"_\", with: \"/\")\n        // Add padding if needed\n        let rem = str.count % 4\n        if rem > 0 { str.append(String(repeating: \"=\", count: 4 - rem)) }\n        return Data(base64Encoded: str)\n    }\n    \n    //\n    \n\n    \n\n    \n\n    \n\n    \n\n    \n    /// Handle incoming public message\n    @MainActor\n    func handlePublicMessage(_ message: BitchatMessage) {\n        let finalMessage = processActionMessage(message)\n\n        // Drop if sender is blocked (covers geohash via Nostr pubkey mapping)\n        if isMessageBlocked(finalMessage) { return }\n\n        // Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system)\n        let isGeo = finalMessage.senderPeerID?.isGeoChat == true\n\n        // Apply per-sender and per-content rate limits (drop if exceeded)\n        // Treat action-style system messages (which carry a senderPeerID) the same as regular user messages\n        let shouldRateLimit = finalMessage.sender != \"system\" || finalMessage.senderPeerID != nil\n        if shouldRateLimit {\n            let senderKey = normalizedSenderKey(for: finalMessage)\n            let contentKey = deduplicationService.normalizedContentKey(finalMessage.content)\n            if !publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) { return }\n        }\n\n        // Size cap: drop extremely large public messages early\n        if finalMessage.sender != \"system\" && finalMessage.content.count > 16000 { return }\n\n        // Persist mesh messages to mesh timeline always\n        if !isGeo && finalMessage.sender != \"system\" {\n            timelineStore.append(finalMessage, to: .mesh)\n        }\n\n        // Persist geochat messages to per-geohash timeline\n        if isGeo && finalMessage.sender != \"system\" {\n            if let gh = currentGeohash {\n                _ = timelineStore.appendIfAbsent(finalMessage, toGeohash: gh)\n            }\n        }\n\n        // Only add message to current timeline if it matches active channel or is system\n        let isSystem = finalMessage.sender == \"system\"\n        let channelMatches: Bool = {\n            switch activeChannel {\n            case .mesh: return !isGeo || isSystem\n            case .location: return isGeo || isSystem\n            }\n        }()\n\n        guard channelMatches else { return }\n\n        // Removed background nudge notification for generic \"new chats!\"\n\n        // Append via batching buffer (skip empty content) with simple dedup by ID\n        if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            if !messages.contains(where: { $0.id == finalMessage.id }) {\n                publicMessagePipeline.enqueue(finalMessage)\n            }\n        }\n    }\n    \n        /// Check for mentions and send notifications\n        \n        func checkForMentions(_ message: BitchatMessage) {    // Determine our acceptable mention token. If any connected peer shares our nickname,\n    // require the disambiguated form '<nickname>#<peerIDprefix>' to trigger.\n    var myTokens: Set<String> = [nickname]\n    let meshPeers = meshService.getPeerNicknames()\n    let collisions = meshPeers.values.filter { $0.hasPrefix(nickname + \"#\") }\n    if !collisions.isEmpty {\n        let suffix = \"#\" + String(meshService.myPeerID.id.prefix(4))\n        myTokens = [nickname + suffix]\n    }\n    let isMentioned = (message.mentions?.contains { myTokens.contains($0) } ?? false)\n\n    if isMentioned && message.sender != nickname {\n        SecureLogger.info(\"🔔 Mention from \\(message.sender)\", category: .session)\n        NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)\n    }\n}\n\n    /// Send haptic feedback for special messages (iOS only)\n    func sendHapticFeedback(for message: BitchatMessage) {        #if os(iOS)\n        guard UIApplication.shared.applicationState == .active else { return }\n        \n        // Build acceptable target tokens: base nickname and, if in a location channel, nickname with '#abcd'\n        var tokens: [String] = [nickname]\n        #if os(iOS)\n        switch activeChannel {\n        case .location(let ch):\n            if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {\n                let d = String(id.publicKeyHex.suffix(4))\n                tokens.append(nickname + \"#\" + d)\n            }\n        case .mesh:\n            break\n        }\n        #endif\n\n        let hugsMe = tokens.contains { message.content.contains(\"hugs \\($0)\") } || message.content.contains(\"hugs you\")\n        let slapsMe = tokens.contains { message.content.contains(\"slaps \\($0) around\") } || message.content.contains(\"slaps you around\")\n\n        let isHugForMe = message.content.contains(\"🫂\") && hugsMe\n        let isSlapForMe = message.content.contains(\"🐟\") && slapsMe\n        \n        if isHugForMe && message.sender != nickname {\n            // Long warm haptic for hugs\n            let impactFeedback = UIImpactFeedbackGenerator(style: .medium)\n            impactFeedback.prepare()\n            \n            for i in 0..<8 {\n                DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds) {\n                    impactFeedback.impactOccurred()\n                }\n            }\n        } else if isSlapForMe && message.sender != nickname {\n            // Sharp haptic for slaps\n            let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)\n            impactFeedback.prepare()\n            impactFeedback.impactOccurred()\n        }\n        #endif\n    }\n}\n// End of ChatViewModel class\n\nextension ChatViewModel: PublicMessagePipelineDelegate {\n    func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {\n        messages\n    }\n\n    func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {\n        self.messages = messages\n    }\n\n    func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {\n        deduplicationService.normalizedContentKey(content)\n    }\n\n    func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {\n        deduplicationService.contentTimestamp(forKey: key)\n    }\n\n    func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {\n        deduplicationService.recordContentKey(key, timestamp: timestamp)\n    }\n\n    func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {\n        trimMessagesIfNeeded()\n    }\n\n    func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {\n        _ = formatMessageAsText(message, colorScheme: currentColorScheme)\n    }\n\n    func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {\n        isBatchingPublic = isBatching\n    }\n}\n"
  },
  {
    "path": "bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift",
    "content": "//\n// ChatViewModel+Nostr.swift\n// bitchat\n//\n// Geohash and Nostr logic for ChatViewModel\n//\n\nimport Foundation\nimport Combine\nimport BitLogger\nimport SwiftUI\nimport Tor\n\nextension ChatViewModel {\n\n    // MARK: - Geohash Subscription\n\n    // Resubscribe to the active geohash channel without clearing timeline\n    @MainActor\n    func resubscribeCurrentGeohash() {\n        guard case .location(let ch) = activeChannel else { return }\n        guard let subID = geoSubscriptionID else {\n            // No existing subscription; set it up\n            switchLocationChannel(to: activeChannel)\n            return\n        }\n        // Ensure participant decay timer is running\n        participantTracker.startRefreshTimer()\n        // Unsubscribe + resubscribe\n        NostrRelayManager.shared.unsubscribe(id: subID)\n        let filter = NostrFilter.geohashEphemeral(\n            ch.geohash,\n            since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),\n            limit: TransportConfig.nostrGeohashInitialLimit\n        )\n        let subRelays = GeoRelayDirectory.shared.closestRelays(\n            toGeohash: ch.geohash,\n            count: TransportConfig.nostrGeoRelayCount\n        )\n        NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in\n            self?.subscribeNostrEvent(event)\n        }\n        // Resubscribe geohash DMs for this identity\n        if let dmSub = geoDmSubscriptionID {\n            NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil\n        }\n        \n        if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {\n            let dmSub = \"geo-dm-\\(ch.geohash)\"\n            geoDmSubscriptionID = dmSub\n            let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))\n            NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in\n                self?.subscribeGiftWrap(giftWrap, id: id)\n            }\n        }\n    }\n    \n    func subscribeNostrEvent(_ event: NostrEvent) {\n        guard event.isValidSignature() else { return }\n        guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || \n               event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),\n              !deduplicationService.hasProcessedNostrEvent(event.id)\n        else {\n            return\n        }\n\n        deduplicationService.recordNostrEvent(event.id)\n        \n        if let gh = currentGeohash,\n           let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh),\n           myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {\n            // Skip very recent self-echo from relay, but allow older events (e.g., after app restart)\n            let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))\n            if Date().timeIntervalSince(eventTime) < 15 {\n                return\n            }\n        }\n        \n        if let nickTag = event.tags.first(where: { $0.first == \"n\" }), nickTag.count >= 2 {\n            let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)\n            geoNicknames[event.pubkey.lowercased()] = nick\n        }\n        \n        // Store mapping for geohash sender IDs used in messages (ensures consistent colors)\n        nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey\n        nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey\n\n        // Update participants last-seen for this pubkey\n        participantTracker.recordParticipant(pubkeyHex: event.pubkey)\n        \n        // If presence heartbeat (Kind 20001), stop here - no content to display\n        if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {\n            return\n        }\n        \n        // Track teleported tag (only our format [\"t\",\"teleport\"]) for icon state\n        let hasTeleportTag = event.tags.contains(where: { tag in\n            tag.count >= 2 && tag[0].lowercased() == \"t\" && tag[1].lowercased() == \"teleport\"\n        })\n        \n        if hasTeleportTag {\n            let key = event.pubkey.lowercased()\n            // Do not mark our own key from historical events; rely on manager.teleported for self\n            let isSelf: Bool = {\n                if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {\n                    return my.publicKeyHex.lowercased() == key\n                }\n                return false\n            }()\n            if !isSelf {\n                Task { @MainActor in\n                    teleportedGeo = teleportedGeo.union([key])\n                }\n            }\n        }\n        \n        let senderName = displayNameForNostrPubkey(event.pubkey)\n        let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)\n        \n        // Clamp future timestamps to now to avoid future-dated messages skewing order\n        let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))\n        let timestamp = min(rawTs, Date())\n        let mentions = parseMentions(from: content)\n        let msg = BitchatMessage(\n            id: event.id,\n            sender: senderName,\n            content: content,\n            timestamp: timestamp,\n            isRelay: false,\n            senderPeerID: PeerID(nostr: event.pubkey),\n            mentions: mentions.isEmpty ? nil : mentions\n        )\n        Task { @MainActor in\n            // BCH-01-012: Check blocking before any notifications\n            // handlePublicMessage has its own blocking check but returns silently,\n            // so we must also guard checkForMentions to prevent notification bypass\n            let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())\n\n            handlePublicMessage(msg)\n\n            // Only check mentions and send haptic if sender is not blocked\n            if !isBlocked {\n                checkForMentions(msg)\n                sendHapticFeedback(for: msg)\n            }\n        }\n    }\n\n    func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {\n        guard giftWrap.isValidSignature() else { return }\n        guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }\n        deduplicationService.recordNostrEvent(giftWrap.id)\n        \n        guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),\n              let packet = Self.decodeEmbeddedBitChatPacket(from: content),\n              packet.type == MessageType.noiseEncrypted.rawValue,\n              let noisePayload = NoisePayload.decode(packet.payload)\n        else {\n            return\n        }\n        \n        let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))\n        let convKey = PeerID(nostr_: senderPubkey)\n        nostrKeyMapping[convKey] = senderPubkey\n        \n        switch noisePayload.type {\n        case .privateMessage:\n            handlePrivateMessage(noisePayload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)\n        case .delivered:\n            handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)\n        case .readReceipt:\n            handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)\n        case .verifyChallenge, .verifyResponse:\n            // QR verification payloads over Nostr are not supported; ignore in geohash DMs\n            break\n        }\n    }\n\n    // MARK: - Geohash Channel Handling\n\n    @MainActor\n    func switchLocationChannel(to channel: ChannelID) {\n        // Reset pending public batches to avoid cross-channel bleed\n        publicMessagePipeline.reset()\n        \n        activeChannel = channel\n        publicMessagePipeline.updateActiveChannel(channel)\n        \n        // Reset deduplication set and optionally hydrate timeline for mesh\n        deduplicationService.clearNostrCaches()\n        switch channel {\n        case .mesh:\n            refreshVisibleMessages(from: .mesh)\n            // Debug: log if any empty messages are present\n            let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count\n            if emptyMesh > 0 {\n                SecureLogger.debug(\"RenderGuard: mesh timeline contains \\(emptyMesh) empty messages\", category: .session)\n            }\n            participantTracker.stopRefreshTimer()\n            participantTracker.setActiveGeohash(nil)\n            teleportedGeo.removeAll()\n        case .location:\n            refreshVisibleMessages(from: channel)\n        }\n        // If switching to a location channel, flush any pending geohash-only system messages\n        if case .location = channel {\n            for content in timelineStore.drainPendingGeohashSystemMessages() {\n                addPublicSystemMessage(content)\n            }\n        }\n        // Unsubscribe previous\n        if let sub = geoSubscriptionID {\n            NostrRelayManager.shared.unsubscribe(id: sub)\n            geoSubscriptionID = nil\n        }\n        if let dmSub = geoDmSubscriptionID {\n            NostrRelayManager.shared.unsubscribe(id: dmSub)\n            geoDmSubscriptionID = nil\n        }\n        currentGeohash = nil\n        participantTracker.setActiveGeohash(nil)\n        // Reset nickname cache for geochat participants\n        geoNicknames.removeAll()\n\n        guard case .location(let ch) = channel else { return }\n        currentGeohash = ch.geohash\n        participantTracker.setActiveGeohash(ch.geohash)\n        \n        // Ensure self appears immediately in the people list; mark teleported state only when truly teleported\n        if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {\n            participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex)\n            let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty\n            let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }\n            let key = id.publicKeyHex.lowercased()\n            if LocationChannelManager.shared.teleported && hasRegional && !inRegional {\n                teleportedGeo = teleportedGeo.union([key])\n                SecureLogger.info(\"GeoTeleport: channel switch mark self teleported key=\\(key.prefix(8))… total=\\(teleportedGeo.count)\", category: .session)\n            } else {\n                teleportedGeo.remove(key)\n            }\n        }\n        \n        let subID = \"geo-\\(ch.geohash)\"\n        geoSubscriptionID = subID\n        participantTracker.startRefreshTimer()\n        let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)\n        let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)\n        let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)\n        NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in\n            self?.handleNostrEvent(event)\n        }\n\n        subscribeToGeoChat(ch)\n    }\n    \n    func handleNostrEvent(_ event: NostrEvent) {\n        guard event.isValidSignature() else { return }\n        // Only handle ephemeral kind 20000 or presence kind 20001 with matching tag\n        guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||\n               event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }\n        \n        // Deduplicate\n        if deduplicationService.hasProcessedNostrEvent(event.id) { return }\n        deduplicationService.recordNostrEvent(event.id)\n        \n        // Log incoming tags for diagnostics\n        let tagSummary = event.tags.map { \"[\" + $0.joined(separator: \",\") + \"]\" }.joined(separator: \",\")\n        SecureLogger.debug(\"GeoTeleport: recv pub=\\(event.pubkey.prefix(8))… tags=\\(tagSummary)\", category: .session)\n        \n        // If this pubkey is blocked, skip mapping, participants, and timeline\n        if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {\n            return\n        }\n        \n        // Track teleport tag for participants – only our format [\"t\", \"teleport\"]\n        let hasTeleportTag: Bool = event.tags.contains { tag in\n            tag.count >= 2 && tag[0].lowercased() == \"t\" && tag[1].lowercased() == \"teleport\"\n        }\n        \n        let isSelf: Bool = {\n            if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {\n                return my.publicKeyHex.lowercased() == event.pubkey.lowercased()\n            }\n            return false\n        }()\n\n        if hasTeleportTag {\n            // Avoid marking our own key from historical events; rely on manager.teleported for self\n            if !isSelf {\n                let key = event.pubkey.lowercased()\n                Task { @MainActor in\n                    teleportedGeo = teleportedGeo.union([key])\n                    SecureLogger.info(\"GeoTeleport: mark peer teleported key=\\(key.prefix(8))… total=\\(teleportedGeo.count)\", category: .session)\n                }\n            }\n        }\n        \n        // Update participants last-seen for this pubkey\n        participantTracker.recordParticipant(pubkeyHex: event.pubkey)\n\n        // Skip only very recent self-echo from relay; include older self events for hydration\n        if isSelf {\n            let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))\n            if Date().timeIntervalSince(eventTime) < 15 {\n                return\n            }\n        }\n        \n        // Cache nickname from tag if present\n        if let nickTag = event.tags.first(where: { $0.first == \"n\" }), nickTag.count >= 2 {\n            let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)\n            geoNicknames[event.pubkey.lowercased()] = nick\n        }\n        \n        // Store mapping for geohash DM initiation\n        nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey\n        nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey\n        \n        // If presence heartbeat (Kind 20001), stop here - no content to display\n        if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {\n            return\n        }\n        \n        let senderName = displayNameForNostrPubkey(event.pubkey)\n        let content = event.content\n        \n        // If this is a teleport presence event (no content), don't add to timeline\n        if let teleTag = event.tags.first(where: { $0.first == \"t\" }), teleTag.count >= 2, (teleTag[1] == \"teleport\"),\n           content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            return\n        }\n        \n        // Clamp future timestamps\n        let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))\n        let mentions = parseMentions(from: content)\n        let msg = BitchatMessage(\n            id: event.id,\n            sender: senderName,\n            content: content,\n            timestamp: min(rawTs, Date()),\n            isRelay: false,\n            senderPeerID: PeerID(nostr: event.pubkey),\n            mentions: mentions.isEmpty ? nil : mentions\n        )\n        \n        Task { @MainActor in\n            handlePublicMessage(msg)\n            checkForMentions(msg)\n            sendHapticFeedback(for: msg)\n        }\n    }\n    \n    @MainActor\n    func subscribeToGeoChat(_ ch: GeohashChannel) {\n        guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return }\n        \n        let dmSub = \"geo-dm-\\(ch.geohash)\"\n        geoDmSubscriptionID = dmSub\n        // pared back logging: subscribe debug only\n        // Log GeoDM subscribe only when Tor is ready to avoid early noise\n        if TorManager.shared.isReady {\n            SecureLogger.debug(\"GeoDM: subscribing DMs pub=\\(id.publicKeyHex.prefix(8))… sub=\\(dmSub)\", category: .session)\n        }\n        let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))\n        NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in\n            self?.handleGiftWrap(giftWrap, id: id)\n        }\n    }\n    \n    func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {\n        guard giftWrap.isValidSignature() else { return }\n        if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {\n            return\n        }\n        deduplicationService.recordNostrEvent(giftWrap.id)\n        \n        // Decrypt with per-geohash identity\n        guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {\n            SecureLogger.warning(\"GeoDM: failed decrypt giftWrap id=\\(giftWrap.id.prefix(8))…\", category: .session)\n            return\n        }\n        \n        SecureLogger.debug(\"GeoDM: decrypted gift-wrap id=\\(giftWrap.id.prefix(16))... from=\\(senderPubkey.prefix(8))...\", category: .session)\n        \n        guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),\n              packet.type == MessageType.noiseEncrypted.rawValue,\n              let payload = NoisePayload.decode(packet.payload)\n        else {\n            return\n        }\n        \n        let convKey = PeerID(nostr_: senderPubkey)\n        nostrKeyMapping[convKey] = senderPubkey\n        \n        switch payload.type {\n        case .privateMessage:\n            let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))\n            handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)\n        case .delivered:\n            handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)\n        case .readReceipt:\n            handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)\n        \n        // Explicitly list other cases so we get compile-time check if a new case is added in the future\n        case .verifyChallenge, .verifyResponse:\n            break\n        }\n    }\n\n    @MainActor\n    func sendGeohash(context: GeoOutgoingContext) {\n        let ch = context.channel\n        let event = context.event\n        let identity = context.identity\n\n        let targetRelays = GeoRelayDirectory.shared.closestRelays(\n            toGeohash: ch.geohash,\n            count: TransportConfig.nostrGeoRelayCount\n        )\n\n        if targetRelays.isEmpty {\n            SecureLogger.warning(\"Geo: no geohash relays available for \\(ch.geohash); not sending\", category: .session)\n        } else {\n            NostrRelayManager.shared.sendEvent(event, to: targetRelays)\n        }\n\n        // Track ourselves as active participant\n        participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)\n        nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex\n        SecureLogger.debug(\"GeoTeleport: sent geo message pub=\\(identity.publicKeyHex.prefix(8))… teleported=\\(context.teleported)\", category: .session)\n\n        // If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI\n        // Only when not in our regional set (and regional list is known)\n        let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty\n        let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }\n\n        if context.teleported && hasRegional && !inRegional {\n            let key = identity.publicKeyHex.lowercased()\n            teleportedGeo = teleportedGeo.union([key])\n            SecureLogger.info(\"GeoTeleport: mark self teleported key=\\(key.prefix(8))… total=\\(teleportedGeo.count)\", category: .session)\n        }\n\n        deduplicationService.recordNostrEvent(event.id)\n    }\n\n    // MARK: - Sampling\n\n    /// Begin sampling multiple geohashes (used by channel sheet) without changing active channel.\n    @MainActor\n    func beginGeohashSampling(for geohashes: [String]) {\n        // Disable sampling when app is backgrounded (Tor is stopped there)\n        if !TorManager.shared.isForeground() {\n            endGeohashSampling()\n            return\n        }\n        // Determine which to add and which to remove\n        let desired = Set(geohashes)\n        let current = Set(geoSamplingSubs.values)\n        let toAdd = desired.subtracting(current)\n        let toRemove = current.subtracting(desired)\n\n        for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {\n            NostrRelayManager.shared.unsubscribe(id: subID)\n            geoSamplingSubs.removeValue(forKey: subID)\n        }\n\n        for gh in toAdd {\n            subscribe(gh)\n        }\n    }\n    \n    @MainActor\n    func subscribe(_ gh: String) {\n        let subID = \"geo-sample-\\(gh)\"\n        geoSamplingSubs[subID] = gh\n        let filter = NostrFilter.geohashEphemeral(\n            gh,\n            since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),\n            limit: TransportConfig.nostrGeohashSampleLimit\n        )\n        let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)\n        NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in\n            self?.subscribeNostrEvent(event, gh: gh)\n        }\n    }\n    \n    func subscribeNostrEvent(_ event: NostrEvent, gh: String) {\n        guard event.isValidSignature() else { return }\n        guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||\n               event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }\n\n        // Compute current participant count (5-minute window) BEFORE updating with this event\n        let existingCount = participantTracker.participantCount(for: gh)\n\n        // Update participants for this specific geohash\n        participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)\n        \n        // Notify only on rising-edge: previously zero people, now someone sends a chat\n        let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !content.isEmpty else { return }\n        \n        // Respect geohash blocks\n        if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }\n        \n        // Skip self identity for this geohash\n        if let my = try? idBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }\n        \n        // Only trigger when there were zero participants in this geohash recently\n        guard existingCount == 0 else { return }\n        \n        // Avoid notifications for old sampled events when launching or (re)subscribing\n        let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))\n        if Date().timeIntervalSince(eventTime) > 30 { return }\n        \n        // Foreground-only notifications: app must be active, and not already viewing this geohash\n        #if os(iOS)\n        guard UIApplication.shared.applicationState == .active else { return }\n        if case .location(let ch) = activeChannel, ch.geohash == gh { return }\n        #elseif os(macOS)\n        guard NSApplication.shared.isActive else { return }\n        if case .location(let ch) = activeChannel, ch.geohash == gh { return }\n        #endif\n        \n        cooldownPerGeohash(gh, content: content, event: event)\n    }\n    \n    func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {\n        let now = Date()\n        let last = lastGeoNotificationAt[gh] ?? .distantPast\n        if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }\n        \n        // Compose a short preview\n        let preview: String = {\n            let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen\n            if content.count <= maxLen { return content }\n            let idx = content.index(content.startIndex, offsetBy: maxLen)\n            return String(content[..<idx]) + \"…\"\n        }()\n\n        Task { @MainActor in\n            lastGeoNotificationAt[gh] = now\n            // Pre-populate the target geohash timeline so the triggering message appears when user opens it\n            let senderSuffix = String(event.pubkey.suffix(4))\n            let nick = geoNicknames[event.pubkey.lowercased()]\n            let senderName = (nick?.isEmpty == false ? nick! : \"anon\") + \"#\" + senderSuffix\n            \n            // Clamp future timestamps\n            let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))\n            let ts = min(rawTs, Date())\n            let mentions = self.parseMentions(from: content)\n            let msg = BitchatMessage(\n                id: event.id,\n                sender: senderName,\n                content: content,\n                timestamp: ts,\n                isRelay: false,\n                senderPeerID: PeerID(nostr: event.pubkey),\n                mentions: mentions.isEmpty ? nil : mentions\n            )\n            if timelineStore.appendIfAbsent(msg, toGeohash: gh) {\n                NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)\n            }\n        }\n    }\n\n    /// Stop sampling all extra geohashes.\n    @MainActor\n    func endGeohashSampling() {\n        for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }\n        geoSamplingSubs.removeAll()\n    }\n\n    // MARK: - Nostr DM Handling\n\n    func setupNostrMessageHandling() {\n        guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else {\n            SecureLogger.warning(\"⚠️ No Nostr identity available for message handling\", category: .session)\n            return\n        }\n        \n        SecureLogger.debug(\"🔑 Setting up Nostr subscription for pubkey: \\(currentIdentity.publicKeyHex.prefix(16))...\", category: .session)\n        \n        // Subscribe to Nostr messages\n        let filter = NostrFilter.giftWrapsFor(\n            pubkey: currentIdentity.publicKeyHex,\n            since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)  // Last 24 hours\n        )\n        \n        nostrRelayManager?.subscribe(filter: filter, id: \"chat-messages\") { [weak self] event in\n            self?.handleNostrMessage(event)\n        }\n    }\n    \n    func handleNostrMessage(_ giftWrap: NostrEvent) {\n        // Deduplicate messages by ID\n        if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return }\n        deduplicationService.recordNostrEvent(giftWrap.id)\n        \n        // Ensure we're on a background queue for decryption\n        Task.detached(priority: .userInitiated) { [weak self] in\n            await self?.processNostrMessage(giftWrap)\n        }\n    }\n    \n    func processNostrMessage(_ giftWrap: NostrEvent) async {\n        guard giftWrap.isValidSignature() else { return }\n        guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }\n        \n        do {\n            let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(\n                giftWrap: giftWrap,\n                recipientIdentity: currentIdentity\n            )\n            \n            // Handle verification payloads first\n            if content.hasPrefix(\"verify:\") {\n                // Ignore verification payloads arriving via Nostr path for now\n                // Verification should ideally happen over mesh for security binding\n                return\n            }\n            \n            // Check if it's a BitChat packet embedded in the content (bitchat1:...)\n            if content.hasPrefix(\"bitchat1:\") {\n                guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {\n                    SecureLogger.error(\"Failed to decode embedded BitChat packet from Nostr DM\", category: .session)\n                    return\n                }\n                \n                // Map sender by Nostr pubkey to Noise key when possible\n                let actualSenderNoiseKey = findNoiseKey(for: senderPubkey)\n                \n                // Stable target ID if we know Noise key; otherwise temporary Nostr-based peer\n                let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)\n                \n                if packet.type == MessageType.noiseEncrypted.rawValue,\n                   let payload = NoisePayload.decode(packet.payload) {\n                    let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))\n                    // Store Nostr mapping\n                    await MainActor.run {\n                        nostrKeyMapping[targetPeerID] = senderPubkey\n                        \n                        // Handle packet types\n                        switch payload.type {\n                        case .privateMessage:\n                            handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)\n                        case .delivered:\n                            handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)\n                        case .readReceipt:\n                            handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)\n                        case .verifyChallenge, .verifyResponse:\n                            break\n                        }\n                    }\n                }\n            } else {\n                SecureLogger.debug(\"Ignoring non-embedded Nostr DM content\", category: .session)\n            }\n        } catch {\n            SecureLogger.error(\"Failed to decrypt Nostr message: \\(error)\", category: .session)\n        }\n    }\n\n    func findNoiseKey(for nostrPubkey: String) -> Data? {\n        // Check favorites for this Nostr key\n        let favorites = FavoritesPersistenceService.shared.favorites.values\n        var npubToMatch = nostrPubkey\n        \n        // Convert hex to npub if needed for comparison\n        if !nostrPubkey.hasPrefix(\"npub\") {\n            if let pubkeyData = Data(hexString: nostrPubkey),\n               let encoded = try? Bech32.encode(hrp: \"npub\", data: pubkeyData) {\n                npubToMatch = encoded\n            } else {\n                SecureLogger.warning(\"⚠️ Invalid hex public key format or encoding failed: \\(nostrPubkey.prefix(16))...\", category: .session)\n            }\n        }\n        \n        for relationship in favorites {\n            // Search through favorites for matching Nostr pubkey\n            if let storedNostrKey = relationship.peerNostrPublicKey {\n                // Compare against stored key (could be hex or npub)\n                if storedNostrKey == npubToMatch {\n                    // SecureLogger.debug(\"✅ Found Noise key for Nostr sender (npub match)\", category: .session)\n                    return relationship.peerNoisePublicKey\n                }\n                \n                // Also try comparing raw hex if stored key is hex\n                if !storedNostrKey.hasPrefix(\"npub\") && storedNostrKey == nostrPubkey {\n                    SecureLogger.debug(\"✅ Found Noise key for Nostr sender (hex match)\", category: .session)\n                    return relationship.peerNoisePublicKey\n                }\n            }\n        }\n        \n        SecureLogger.debug(\"⚠️ No matching Noise key found for Nostr pubkey: \\(nostrPubkey.prefix(16))... (tried npub: \\(npubToMatch.prefix(16))...)\", category: .session)\n        return nil\n    }\n\n    func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) {\n        // If we have a Noise key, try to route securely if possible, otherwise fallback to direct\n        if let _ = key {\n             // Ideally we would use MessageRouter here, but for simplicity in this direct callback:\n             // check if we have an identity\n             if let id = try? idBridge.getCurrentNostrIdentity() {\n                 let nt = NostrTransport(keychain: keychain, idBridge: idBridge)\n                 nt.senderPeerID = meshService.myPeerID\n                 nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)\n             }\n        } else if let id = try? idBridge.getCurrentNostrIdentity() {\n            // Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey\n            let nt = NostrTransport(keychain: keychain, idBridge: idBridge)\n            nt.senderPeerID = meshService.myPeerID\n            nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)\n            SecureLogger.debug(\"Sent DELIVERED ack directly to Nostr pub=\\(senderPubkey.prefix(8))… for mid=\\(message.id.prefix(8))…\", category: .session)\n        }\n        \n        // Same for READ receipt if viewing\n        if !wasReadBefore && selectedPrivateChatPeer == message.senderPeerID {\n             if let _ = key {\n                 if let id = try? idBridge.getCurrentNostrIdentity() {\n                     let nt = NostrTransport(keychain: keychain, idBridge: idBridge)\n                     nt.senderPeerID = meshService.myPeerID\n                     nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)\n                 }\n             } else if let id = try? idBridge.getCurrentNostrIdentity() {\n                 let nt = NostrTransport(keychain: keychain, idBridge: idBridge)\n                 nt.senderPeerID = meshService.myPeerID\n                 nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)\n                 SecureLogger.debug(\"Viewing chat; sent READ ack directly to Nostr pub=\\(senderPubkey.prefix(8))… for mid=\\(message.id.prefix(8))…\", category: .session)\n             }\n        }\n    }\n\n    func handleFavoriteNotification(content: String, from nostrPubkey: String) {\n        // Try to find Noise key associated with this Nostr pubkey\n        guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return }\n        \n        let isFavorite = content.contains(\"FAVORITE:TRUE\")\n        let senderNickname = content.components(separatedBy: \"|\").last ?? \"Unknown\"\n        \n        // Update favorite status\n        if isFavorite {\n            FavoritesPersistenceService.shared.addFavorite(\n                peerNoisePublicKey: senderNoiseKey,\n                peerNostrPublicKey: nostrPubkey,\n                peerNickname: senderNickname\n            )\n        } else {\n            // Only remove if we don't have it set locally\n            // Logic handled by persistence service usually, here we just update remote state\n            // Actually for now we just process the notification\n        }\n        \n        // Extract Nostr public key if included\n        var extractedNostrPubkey: String? = nil\n        if let range = content.range(of: \"NPUB:\") {\n            let suffix = content[range.upperBound...]\n            let parts = suffix.components(separatedBy: \"|\")\n            if let key = parts.first {\n                extractedNostrPubkey = String(key)\n            }\n        } else if content.contains(\":\") {\n             // Fallback: simple format FAVORITE:TRUE:npub...\n             let parts = content.components(separatedBy: \":\")\n             if parts.count >= 3 {\n                 extractedNostrPubkey = String(parts[2])\n             }\n        }\n        \n        SecureLogger.info(\"📝 Received favorite notification from \\(senderNickname): \\(isFavorite)\", category: .session)\n        \n        // If they favorited us and provided their Nostr key, ensure it's stored\n        if isFavorite && extractedNostrPubkey != nil {\n            SecureLogger.info(\"💾 Storing Nostr key association for \\(senderNickname): \\(extractedNostrPubkey!.prefix(16))...\", category: .session)\n             FavoritesPersistenceService.shared.addFavorite(\n                peerNoisePublicKey: senderNoiseKey,\n                peerNostrPublicKey: extractedNostrPubkey,\n                peerNickname: senderNickname\n            )\n        }\n        \n        // Show notification\n        NotificationService.shared.sendLocalNotification(\n            title: isFavorite ? \"New Favorite\" : \"Favorite Removed\",\n            body: \"\\(senderNickname) \\(isFavorite ? \"favorited\" : \"unfavorited\") you\",\n            identifier: \"fav-\\(UUID().uuidString)\"\n        )\n    }\n\n    func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {\n        // Find peer Nostr key\n        guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),\n              relationship.peerNostrPublicKey != nil else {\n            SecureLogger.warning(\"⚠️ Cannot send favorite notification - no Nostr key for peer\", category: .session)\n            return\n        }\n        \n        let peerID = PeerID(hexData: noisePublicKey)\n        \n        // Route via message router\n        messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)\n    }\n\n    private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {\n        guard content.hasPrefix(\"bitchat1:\") else { return nil }\n        let encoded = String(content.dropFirst(\"bitchat1:\".count))\n        let maxBytes = FileTransferLimits.maxFramedFileBytes\n        // Base64url length upper bound for maxBytes (padded length; unpadded is <= this).\n        let maxEncoded = ((maxBytes + 2) / 3) * 4\n        guard encoded.count <= maxEncoded else { return nil }\n        guard let packetData = Self.base64URLDecode(encoded),\n              packetData.count <= maxBytes\n        else { return nil }\n        return BitchatPacket.from(packetData)\n    }\n\n    // MARK: - Geohash Nickname Resolution (for /block in geohash)\n\n    func nostrPubkeyForDisplayName(_ name: String) -> String? {\n        // Look up current visible geohash participants for an exact displayName match\n        for p in visibleGeohashPeople() {\n            if p.displayName == name {\n                return p.id\n            }\n        }\n        // Also check nickname cache directly\n        for (pub, nick) in geoNicknames {\n            if nick == name { return pub }\n        }\n        return nil\n    }\n    \n    func startGeohashDM(withPubkeyHex hex: String) {\n        let convKey = PeerID(nostr_: hex)\n        nostrKeyMapping[convKey] = hex\n        startPrivateChat(with: convKey)\n    }\n    \n    func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {\n        return nostrKeyMapping[senderID]\n    }\n    \n    func geohashDisplayName(for convKey: PeerID) -> String {\n        guard let full = nostrKeyMapping[convKey] else {\n            return convKey.bare\n        }\n        return displayNameForNostrPubkey(full)\n    }\n}\n"
  },
  {
    "path": "bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift",
    "content": "//\n// ChatViewModel+PrivateChat.swift\n// bitchat\n//\n// Private chat and media transfer logic for ChatViewModel\n//\n\nimport Foundation\nimport Combine\nimport BitLogger\nimport SwiftUI\n\nextension ChatViewModel {\n\n    // MARK: - Private Chat Sending\n\n    /// Sends an encrypted private message to a specific peer.\n    /// - Parameters:\n    ///   - content: The message content to encrypt and send\n    ///   - peerID: The recipient's peer ID\n    /// - Note: Automatically establishes Noise encryption if not already active\n    @MainActor\n    func sendPrivateMessage(_ content: String, to peerID: PeerID) {\n        guard !content.isEmpty else { return }\n        \n        // Check if blocked\n        if unifiedPeerService.isBlocked(peerID) {\n            let nickname = meshService.peerNickname(peerID: peerID) ?? \"user\"\n            addSystemMessage(\n                String(\n                    format: String(localized: \"system.dm.blocked_recipient\", comment: \"System message when attempting to message a blocked user\"),\n                    locale: .current,\n                    nickname\n                )\n            )\n            return\n        }\n        \n        // Geohash DM routing: conversation keys start with \"nostr_\"\n        if peerID.isGeoDM {\n            sendGeohashDM(content, to: peerID)\n            return\n        }\n        \n        // Determine routing method and recipient nickname\n        guard let noiseKey = Data(hexString: peerID.id) else { return }\n        let isConnected = meshService.isPeerConnected(peerID)\n        let isReachable = meshService.isPeerReachable(peerID)\n        let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)\n        let isMutualFavorite = favoriteStatus?.isMutual ?? false\n        let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil\n        \n        // Get nickname from various sources\n        var recipientNickname = meshService.peerNickname(peerID: peerID)\n        if recipientNickname == nil && favoriteStatus != nil {\n            recipientNickname = favoriteStatus?.peerNickname\n        }\n        recipientNickname = recipientNickname ?? \"user\"\n        \n        // Generate message ID\n        let messageID = UUID().uuidString\n        \n        // Create the message object\n        let message = BitchatMessage(\n            id: messageID,\n            sender: nickname,\n            content: content,\n            timestamp: Date(),\n            isRelay: false,\n            originalSender: nil,\n            isPrivate: true,\n            recipientNickname: recipientNickname,\n            senderPeerID: meshService.myPeerID,\n            mentions: nil,\n            deliveryStatus: .sending\n        )\n        \n        // Add to local chat\n        if privateChats[peerID] == nil {\n            privateChats[peerID] = []\n        }\n        privateChats[peerID]?.append(message)\n        \n        // Trigger UI update for sent message\n        objectWillChange.send()\n        \n        // Send via appropriate transport (BLE if connected/reachable, else Nostr when possible)\n        if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {\n            messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? \"user\", messageID: messageID)\n            // Optimistically mark as sent for both transports; delivery/read will update subsequently\n            if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {\n                privateChats[peerID]?[idx].deliveryStatus = .sent\n            }\n        } else {\n            // Update delivery status to failed\n            if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {\n                privateChats[peerID]?[index].deliveryStatus = .failed(\n                    reason: String(localized: \"content.delivery.reason.unreachable\", comment: \"Failure reason when a peer is unreachable\")\n                )\n            }\n            let name = recipientNickname ?? \"user\"\n            addSystemMessage(\n                String(\n                    format: String(localized: \"system.dm.unreachable\", comment: \"System message when a recipient is unreachable\"),\n                    locale: .current,\n                    name\n                )\n            )\n        }\n    }\n    \n    func sendGeohashDM(_ content: String, to peerID: PeerID) {\n        guard case .location(let ch) = activeChannel else {\n            addSystemMessage(\n                String(localized: \"system.location.not_in_channel\", comment: \"System message when attempting to send without being in a location channel\")\n            )\n            return\n        }\n        let messageID = UUID().uuidString\n        \n        // Local echo in the DM thread\n        let message = BitchatMessage(\n            id: messageID,\n            sender: nickname,\n            content: content,\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: true,\n            recipientNickname: nickname,\n            senderPeerID: meshService.myPeerID,\n            deliveryStatus: .sending\n        )\n        \n        if privateChats[peerID] == nil {\n            privateChats[peerID] = []\n        }\n        \n        privateChats[peerID]?.append(message)\n        objectWillChange.send()\n\n        // Resolve recipient hex from mapping\n        guard let recipientHex = nostrKeyMapping[peerID] else {\n            if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {\n                privateChats[peerID]?[msgIdx].deliveryStatus = .failed(\n                    reason: String(localized: \"content.delivery.reason.unknown_recipient\", comment: \"Failure reason when the recipient is unknown\")\n                )\n            }\n            return\n        }\n        \n        // Respect geohash blocks\n        if identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) {\n            if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {\n                privateChats[peerID]?[msgIdx].deliveryStatus = .failed(\n                    reason: String(localized: \"content.delivery.reason.blocked\", comment: \"Failure reason when the user is blocked\")\n                )\n            }\n            addSystemMessage(\n                String(localized: \"system.dm.blocked_generic\", comment: \"System message when sending fails because user is blocked\")\n            )\n            return\n        }\n        \n        // Send via Nostr using per-geohash identity\n        do {\n            let id = try idBridge.deriveIdentity(forGeohash: ch.geohash)\n            // Prevent messaging ourselves\n            if recipientHex.lowercased() == id.publicKeyHex.lowercased() {\n                if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {\n                privateChats[peerID]?[idx].deliveryStatus = .failed(\n                    reason: String(localized: \"content.delivery.reason.self\", comment: \"Failure reason when attempting to message yourself\")\n                )\n            }\n                return\n            }\n            SecureLogger.debug(\"GeoDM: local send mid=\\(messageID.prefix(8))… to=\\(recipientHex.prefix(8))… conv=\\(peerID)\", category: .session)\n            let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)\n            nostrTransport.senderPeerID = meshService.myPeerID\n            nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)\n            if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {\n                privateChats[peerID]?[msgIdx].deliveryStatus = .sent\n            }\n        } catch {\n            if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {\n                privateChats[peerID]?[idx].deliveryStatus = .failed(\n                    reason: String(localized: \"content.delivery.reason.send_error\", comment: \"Failure reason for a generic send error\")\n                )\n            }\n        }\n    }\n\n    // MARK: - Private Chat Handling (Geohash/Ephemeral)\n\n    func handlePrivateMessage(\n        _ payload: NoisePayload,\n        senderPubkey: String,\n        convKey: PeerID,\n        id: NostrIdentity,\n        messageTimestamp: Date\n    ) {\n        guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }\n        let messageId = pm.messageID\n        \n        SecureLogger.info(\"GeoDM: recv PM <- sender=\\(senderPubkey.prefix(8))… mid=\\(messageId.prefix(8))…\", category: .session)\n\n        sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)\n\n        // Respect geohash blocks\n        if identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {\n            return\n        }\n\n        // Duplicate check\n        if privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }\n        for (_, arr) in privateChats {\n            if arr.contains(where: { $0.id == messageId }) {\n                return\n            }\n        }\n        \n        let senderName = displayNameForNostrPubkey(senderPubkey)\n        let msg = BitchatMessage(\n            id: messageId,\n            sender: senderName,\n            content: pm.content,\n            timestamp: messageTimestamp,\n            isRelay: false,\n            isPrivate: true,\n            recipientNickname: nickname,\n            senderPeerID: convKey,\n            deliveryStatus: .delivered(to: nickname, at: Date())\n        )\n        \n        if privateChats[convKey] == nil {\n            privateChats[convKey] = []\n        }\n        privateChats[convKey]?.append(msg)\n        \n        let isViewing = selectedPrivateChatPeer == convKey\n        let wasReadBefore = sentReadReceipts.contains(messageId)\n        let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30\n        let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage\n        if shouldMarkUnread {\n            unreadPrivateMessages.insert(convKey)\n        }\n        \n        // Send READ if viewing this conversation\n        if isViewing {\n            sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)\n        }\n        \n        // Notify for truly unread and recent messages when not viewing\n        if !isViewing && shouldMarkUnread {\n            NotificationService.shared.sendPrivateMessageNotification(\n                from: senderName,\n                message: pm.content,\n                peerID: convKey\n            )\n        }\n        \n        objectWillChange.send()\n    }\n    \n    func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {\n        guard let messageID = String(data: payload.data, encoding: .utf8) else { return }\n        \n        if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {\n            privateChats[convKey]?[idx].deliveryStatus = .delivered(to: displayNameForNostrPubkey(senderPubkey), at: Date())\n            objectWillChange.send()\n            SecureLogger.info(\"GeoDM: recv DELIVERED for mid=\\(messageID.prefix(8))… from=\\(senderPubkey.prefix(8))…\", category: .session)\n        } else {\n            SecureLogger.warning(\"GeoDM: delivered ack for unknown mid=\\(messageID.prefix(8))… conv=\\(convKey)\", category: .session)\n        }\n    }\n    \n    func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {\n        guard let messageID = String(data: payload.data, encoding: .utf8) else { return }\n        \n        if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {\n            privateChats[convKey]?[idx].deliveryStatus = .read(by: displayNameForNostrPubkey(senderPubkey), at: Date())\n            objectWillChange.send()\n            SecureLogger.info(\"GeoDM: recv READ for mid=\\(messageID.prefix(8))… from=\\(senderPubkey.prefix(8))…\", category: .session)\n        } else {\n            SecureLogger.warning(\"GeoDM: read ack for unknown mid=\\(messageID.prefix(8))… conv=\\(convKey)\", category: .session)\n        }\n    }\n\n    func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {\n        guard !sentGeoDeliveryAcks.contains(messageId) else { return }\n        let nt = NostrTransport(keychain: keychain, idBridge: idBridge)\n        nt.senderPeerID = meshService.myPeerID\n        nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id)\n        sentGeoDeliveryAcks.insert(messageId)\n    }\n    \n    func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {\n        guard !sentReadReceipts.contains(messageId) else { return }\n        let nt = NostrTransport(keychain: keychain, idBridge: idBridge)\n        nt.senderPeerID = meshService.myPeerID\n        nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id)\n        sentReadReceipts.insert(messageId)\n    }\n\n    // MARK: - Media Transfers\n\n    private enum MediaSendError: Error {\n        case encodingFailed\n        case tooLarge\n        case copyFailed\n    }\n\n    @MainActor\n    func sendVoiceNote(at url: URL) {\n        guard canSendMediaInCurrentContext else {\n            SecureLogger.info(\"Voice note blocked outside mesh/private context\", category: .session)\n            try? FileManager.default.removeItem(at: url)\n            addSystemMessage(\"Voice notes are only available in mesh chats.\")\n            return\n        }\n\n        let targetPeer = selectedPrivateChatPeer\n        let message = enqueueMediaMessage(content: \"[voice] \\(url.lastPathComponent)\", targetPeer: targetPeer)\n        let messageID = message.id\n        let transferId = makeTransferID(messageID: messageID)\n\n        Task.detached(priority: .userInitiated) { [weak self] in\n            guard let self = self else { return }\n            do {\n                // Security H1: Check file size BEFORE reading into memory\n                let attrs = try FileManager.default.attributesOfItem(atPath: url.path)\n                guard let fileSize = attrs[.size] as? Int,\n                      fileSize <= FileTransferLimits.maxVoiceNoteBytes else {\n                    let size = (attrs[.size] as? Int) ?? 0\n                    SecureLogger.warning(\"Voice note exceeds size limit (\\(size) bytes)\", category: .session)\n                    try? FileManager.default.removeItem(at: url)\n                    await MainActor.run {\n                        self.handleMediaSendFailure(messageID: messageID, reason: \"Voice note too large\")\n                    }\n                    return\n                }\n\n                let data = try Data(contentsOf: url)\n                let packet = BitchatFilePacket(\n                    fileName: url.lastPathComponent,\n                    fileSize: UInt64(data.count),\n                    mimeType: \"audio/mp4\",\n                    content: data\n                )\n                guard packet.encode() != nil else { throw MediaSendError.encodingFailed }\n                await MainActor.run {\n                    self.registerTransfer(transferId: transferId, messageID: messageID)\n                    if let peerID = targetPeer {\n                        self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)\n                    } else {\n                        self.meshService.sendFileBroadcast(packet, transferId: transferId)\n                    }\n                }\n            } catch {\n                SecureLogger.error(\"Voice note send failed: \\(error)\", category: .session)\n                await MainActor.run {\n                    self.handleMediaSendFailure(messageID: messageID, reason: \"Failed to send voice note\")\n                }\n            }\n        }\n    }\n\n    @MainActor\n    func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {\n        guard canSendMediaInCurrentContext else {\n            SecureLogger.info(\"Image send blocked outside mesh/private context\", category: .session)\n            cleanup?()\n            addSystemMessage(\"Images are only available in mesh chats.\")\n            return\n        }\n\n        let targetPeer = selectedPrivateChatPeer\n\n        Task.detached(priority: .userInitiated) { [weak self] in\n            guard let self = self else { return }\n            var processedURL: URL?\n            do {\n                let outputURL = try ImageUtils.processImage(at: sourceURL)\n                processedURL = outputURL\n                let data = try Data(contentsOf: outputURL)\n                guard data.count <= FileTransferLimits.maxImageBytes else {\n                    SecureLogger.warning(\"Processed image exceeds size limit (\\(data.count) bytes)\", category: .session)\n                    await MainActor.run {\n                        self.addSystemMessage(\"Image is too large to send.\")\n                    }\n                    try? FileManager.default.removeItem(at: outputURL)\n                    return\n                }\n                let packet = BitchatFilePacket(\n                    fileName: outputURL.lastPathComponent,\n                    fileSize: UInt64(data.count),\n                    mimeType: \"image/jpeg\",\n                    content: data\n                )\n                guard packet.encode() != nil else { throw MediaSendError.encodingFailed }\n                await MainActor.run {\n                    let message = self.enqueueMediaMessage(content: \"[image] \\(outputURL.lastPathComponent)\", targetPeer: targetPeer)\n                    let messageID = message.id\n                    let transferId = self.makeTransferID(messageID: messageID)\n                    self.registerTransfer(transferId: transferId, messageID: messageID)\n                    if let peerID = targetPeer {\n                        self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)\n                    } else {\n                        self.meshService.sendFileBroadcast(packet, transferId: transferId)\n                    }\n                }\n            } catch {\n                SecureLogger.error(\"Image send preparation failed: \\(error)\", category: .session)\n                await MainActor.run {\n                    self.addSystemMessage(\"Failed to prepare image for sending.\")\n                }\n                if let url = processedURL {\n                    try? FileManager.default.removeItem(at: url)\n                }\n            }\n        }\n    }\n\n    @MainActor\n    func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {\n        let timestamp = Date()\n        let message: BitchatMessage\n\n        if let peerID = targetPeer {\n            message = BitchatMessage(\n                sender: nickname,\n                content: content,\n                timestamp: timestamp,\n                isRelay: false,\n                originalSender: nil,\n                isPrivate: true,\n                recipientNickname: nicknameForPeer(peerID),\n                senderPeerID: meshService.myPeerID,\n                deliveryStatus: .sending\n            )\n            var chats = privateChats\n            chats[peerID, default: []].append(message)\n            privateChats = chats\n            trimMessagesIfNeeded()\n        } else {\n            let (displayName, senderPeerID) = currentPublicSender()\n            message = BitchatMessage(\n                sender: displayName,\n                content: content,\n                timestamp: timestamp,\n                isRelay: false,\n                originalSender: nil,\n                isPrivate: false,\n                recipientNickname: nil,\n                senderPeerID: senderPeerID,\n                deliveryStatus: .sending\n            )\n            timelineStore.append(message, to: activeChannel)\n            messages = timelineStore.messages(for: activeChannel)\n            trimMessagesIfNeeded()\n        }\n\n        let key = deduplicationService.normalizedContentKey(message.content)\n        deduplicationService.recordContentKey(key, timestamp: timestamp)\n        objectWillChange.send()\n        return message\n    }\n\n    @MainActor\n    func registerTransfer(transferId: String, messageID: String) {\n        transferIdToMessageIDs[transferId, default: []].append(messageID)\n        messageIDToTransferId[messageID] = transferId\n    }\n\n    func makeTransferID(messageID: String) -> String {\n        \"\\(messageID)-\\(UUID().uuidString)\"\n    }\n\n    @MainActor\n    func clearTransferMapping(for messageID: String) {\n        guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }\n        guard var queue = transferIdToMessageIDs[transferId] else { return }\n        if !queue.isEmpty {\n            if queue.first == messageID {\n                queue.removeFirst()\n            } else if let idx = queue.firstIndex(of: messageID) {\n                queue.remove(at: idx)\n            }\n        }\n        transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue\n    }\n\n    @MainActor\n    func handleMediaSendFailure(messageID: String, reason: String) {\n        updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))\n        clearTransferMapping(for: messageID)\n    }\n\n    @MainActor\n    func handleTransferEvent(_ event: TransferProgressManager.Event) {\n        switch event {\n        case .started(let id, let total):\n            guard let messageID = transferIdToMessageIDs[id]?.first else { return }\n            updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))\n        case .updated(let id, let sent, let total):\n            guard let messageID = transferIdToMessageIDs[id]?.first else { return }\n            updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))\n        case .completed(let id, _):\n            guard let messageID = transferIdToMessageIDs[id]?.first else { return }\n            updateMessageDeliveryStatus(messageID, status: .sent)\n            clearTransferMapping(for: messageID)\n        case .cancelled(let id, _, _):\n            guard let messageID = transferIdToMessageIDs[id]?.first else { return }\n            clearTransferMapping(for: messageID)\n            removeMessage(withID: messageID, cleanupFile: true)\n        }\n    }\n\n    func cleanupLocalFile(forMessage message: BitchatMessage) {\n        // Check both outgoing and incoming directories for thorough cleanup\n        let prefixes = [\"[voice] \", \"[image] \", \"[file] \"]\n        let subdirs = [\"voicenotes/outgoing\", \"voicenotes/incoming\",\n                       \"images/outgoing\", \"images/incoming\",\n                       \"files/outgoing\", \"files/incoming\"]\n\n        guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return }\n        let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return }\n\n        // Security: Extract only the last path component to prevent directory traversal\n        let safeFilename = (rawFilename as NSString).lastPathComponent\n        guard !safeFilename.isEmpty && safeFilename != \".\" && safeFilename != \"..\" else { return }\n\n        // Try all possible locations (outgoing and incoming)\n        for subdir in subdirs {\n            let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)\n\n            // Security: Verify target is within expected directory before deletion\n            guard target.path.hasPrefix(base.path) else { continue }\n\n            do {\n                try FileManager.default.removeItem(at: target)\n            } catch CocoaError.fileNoSuchFile {\n                // Expected - file not in this directory\n            } catch {\n                SecureLogger.error(\"Failed to cleanup \\(safeFilename): \\(error)\", category: .session)\n            }\n        }\n    }\n\n    func applicationFilesDirectory() throws -> URL {\n        let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)\n        let filesDir = base.appendingPathComponent(\"files\", isDirectory: true)\n        try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)\n        return filesDir\n    }\n\n    @MainActor\n    func cancelMediaSend(messageID: String) {\n        if let transferId = messageIDToTransferId[messageID],\n           let active = transferIdToMessageIDs[transferId]?.first,\n           active == messageID {\n            meshService.cancelTransfer(transferId)\n        }\n        clearTransferMapping(for: messageID)\n        removeMessage(withID: messageID, cleanupFile: true)\n    }\n\n    @MainActor\n    func deleteMediaMessage(messageID: String) {\n        clearTransferMapping(for: messageID)\n        removeMessage(withID: messageID, cleanupFile: true)\n    }\n    \n    // MARK: - Private Chat Handling (Main)\n\n    @MainActor\n    func handlePrivateMessage(\n        _ payload: NoisePayload,\n        actualSenderNoiseKey: Data?,\n        senderNickname: String,\n        targetPeerID: PeerID,\n        messageTimestamp: Date,\n        senderPubkey: String\n    ) {\n        guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }\n        let messageId = pm.messageID\n        let messageContent = pm.content\n\n        // Favorite/unfavorite notifications embedded as private messages\n        if messageContent.hasPrefix(\"[FAVORITED]\") || messageContent.hasPrefix(\"[UNFAVORITED]\") {\n            if let key = actualSenderNoiseKey {\n                handleFavoriteNotificationFromMesh(messageContent, from: PeerID(hexData: key), senderNickname: senderNickname)\n            }\n            return\n        }\n\n        if isDuplicateMessage(messageId, targetPeerID: targetPeerID) {\n            return\n        }\n\n        let wasReadBefore = sentReadReceipts.contains(messageId)\n\n        // Is viewing?\n        var isViewingThisChat = false\n        if selectedPrivateChatPeer == targetPeerID {\n            isViewingThisChat = true\n        } else if let selectedPeer = selectedPrivateChatPeer,\n                  let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer),\n                  let key = actualSenderNoiseKey,\n                  selectedPeerData.noisePublicKey == key {\n            isViewingThisChat = true\n        }\n\n        // Recency check\n        let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30\n        let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage\n\n        let message = BitchatMessage(\n            id: messageId,\n            sender: senderNickname,\n            content: messageContent,\n            timestamp: messageTimestamp,\n            isRelay: false,\n            isPrivate: true,\n            recipientNickname: nickname,\n            senderPeerID: targetPeerID,\n            deliveryStatus: .delivered(to: nickname, at: Date())\n        )\n        \n        addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)\n        mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)\n\n        // Using simplified internal helper in this file (or make the main one internal)\n        // sendDeliveryAckViaNostrEmbedded is in ChatViewModel+Nostr.swift and is internal.\n        // However, it was missing in ChatViewModel+Nostr.swift in previous step check?\n        // Wait, I added `sendDeliveryAckViaNostrEmbedded` to `ChatViewModel+Nostr.swift` in Step 19?\n        // Let's re-check `ChatViewModel+Nostr.swift` content in my mind.\n        // I see `sendDeliveryAckViaNostrEmbedded` in `ChatViewModel+Nostr.swift` in the output of step 33.\n        // So I can call it.\n        sendDeliveryAckViaNostrEmbedded(\n            message,\n            wasReadBefore: wasReadBefore,\n            senderPubkey: senderPubkey,\n            key: actualSenderNoiseKey\n        )\n\n        if wasReadBefore {\n            // do nothing\n        } else if isViewingThisChat {\n            handleViewingThisChat(\n                message,\n                targetPeerID: targetPeerID,\n                key: actualSenderNoiseKey,\n                senderPubkey: senderPubkey\n            )\n        } else {\n            markAsUnreadIfNeeded(\n                shouldMarkAsUnread: shouldMarkAsUnread,\n                targetPeerID: targetPeerID,\n                key: actualSenderNoiseKey,\n                isRecentMessage: isRecentMessage,\n                senderNickname: senderNickname,\n                messageContent: messageContent\n            )\n        }\n\n        objectWillChange.send()\n    }\n    \n    /// Handle incoming private message (Mesh)\n    @MainActor\n    func handlePrivateMessage(_ message: BitchatMessage) {\n        SecureLogger.debug(\"📥 handlePrivateMessage called for message from \\(message.sender)\", category: .session)\n        let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender)\n        \n        guard let peerID = senderPeerID else { \n            SecureLogger.warning(\"⚠️ Could not get peer ID for sender \\(message.sender)\", category: .session)\n            return \n        }\n        \n        // Check if this is a favorite/unfavorite notification\n        if message.content.hasPrefix(\"[FAVORITED]\") || message.content.hasPrefix(\"[UNFAVORITED]\") {\n            handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender)\n            return  // Don't store as a regular message\n        }\n        \n        // Migrate chats if needed\n        migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender)\n        \n        // IMPORTANT: Also consolidate messages from stable Noise key if this is an ephemeral peer\n        // This ensures Nostr messages appear in BLE chats\n        if peerID.id.count == 16 {  // This is an ephemeral peer ID (8 bytes = 16 hex chars)\n            if let peer = unifiedPeerService.getPeer(by: peerID) {\n                let stableKeyHex = PeerID(hexData: peer.noisePublicKey)\n                \n                // If we have messages stored under the stable key, merge them\n                if stableKeyHex != peerID, let nostrMessages = privateChats[stableKeyHex], !nostrMessages.isEmpty {\n                    // Merge messages from stable key into ephemeral peer ID storage\n                    if privateChats[peerID] == nil {\n                        privateChats[peerID] = []\n                    }\n                    \n                    // Add any messages that aren't already in the ephemeral storage\n                    let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])\n                    for nostrMessage in nostrMessages {\n                        if !existingMessageIds.contains(nostrMessage.id) {\n                            privateChats[peerID]?.append(nostrMessage)\n                        }\n                    }\n                    \n                    // Sort by timestamp\n                    privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }\n                    \n                    // Clean up the stable key storage to avoid duplication\n                    privateChats.removeValue(forKey: stableKeyHex)\n                    \n                    SecureLogger.info(\"📥 Consolidated \\(nostrMessages.count) Nostr messages from stable key to ephemeral peer \\(peerID)\", category: .session)\n                }\n            }\n        }\n        \n        // Avoid duplicates\n        if isDuplicateMessage(message.id, targetPeerID: peerID) {\n            return\n        }\n\n        // Store the message\n        addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID)\n        \n        // Mirror to ephemeral if needed (if we are talking to a stable key peer but have an ephemeral session)\n        // Actually, logic usually mirrors TO stable key storage if available?\n        // Or mirrors to ephemeral if we received on stable.\n        // Let's just use the existing helper which seems to mirror TO ephemeral.\n        // But we need to get the noise key.\n        let noiseKey = peerID.noiseKey ?? unifiedPeerService.getPeer(by: peerID)?.noisePublicKey\n        mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey)\n\n        // Notifications and Read Receipts\n        let isViewing = selectedPrivateChatPeer == peerID\n        \n        if isViewing {\n            // Mark read immediately if viewing\n            // Use the incoming peerID directly - it has the established Noise session.\n            // Don't use PeerID(hexData: noiseKey) as that creates a 64-hex ID without a session.\n            // Use meshService directly (not messageRouter) so it queues if peer disconnects.\n            let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)\n            meshService.sendReadReceipt(receipt, to: peerID)\n            sentReadReceipts.insert(message.id)\n        } else {\n            // Notify\n            unreadPrivateMessages.insert(peerID)\n            NotificationService.shared.sendPrivateMessageNotification(\n                from: message.sender,\n                message: message.content,\n                peerID: peerID\n            )\n        }\n        \n        objectWillChange.send()\n    }\n\n    func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool {\n        if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {\n            return true\n        }\n        for (_, messages) in privateChats where messages.contains(where: { $0.id == messageId }) {\n            return true\n        }\n        return false\n    }\n    \n    func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {\n        if privateChats[targetPeerID] == nil {\n            privateChats[targetPeerID] = []\n        }\n        if let idx = privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) {\n            privateChats[targetPeerID]?[idx] = message\n        } else {\n            privateChats[targetPeerID]?.append(message)\n        }\n        // Sanitize to avoid duplicate IDs\n        privateChatManager.sanitizeChat(for: targetPeerID)\n    }\n    \n    @MainActor\n    func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) {\n        guard let key,\n              let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID,\n              ephemeralPeerID != targetPeerID\n        else {\n            return\n        }\n        \n        if privateChats[ephemeralPeerID] == nil {\n            privateChats[ephemeralPeerID] = []\n        }\n        if let idx = privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) {\n            privateChats[ephemeralPeerID]?[idx] = message\n        } else {\n            privateChats[ephemeralPeerID]?.append(message)\n        }\n        privateChatManager.sanitizeChat(for: ephemeralPeerID)\n    }\n    \n    @MainActor\n    func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?, senderPubkey: String) {\n        unreadPrivateMessages.remove(targetPeerID)\n        if let key,\n           let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID {\n            unreadPrivateMessages.remove(ephemeralPeerID)\n        }\n        if !sentReadReceipts.contains(message.id) {\n            if let key {\n                let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)\n                SecureLogger.debug(\"Viewing chat; sending READ ack for \\(message.id.prefix(8))… via router\", category: .session)\n                messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))\n                sentReadReceipts.insert(message.id)\n            } else if let id = try? idBridge.getCurrentNostrIdentity() {\n                let nt = NostrTransport(keychain: keychain, idBridge: idBridge)\n                nt.senderPeerID = meshService.myPeerID\n                nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)\n                sentReadReceipts.insert(message.id)\n                SecureLogger.debug(\"Viewing chat; sent READ ack directly to Nostr pub=\\(senderPubkey.prefix(8))… for mid=\\(message.id.prefix(8))…\", category: .session)\n            }\n        }\n    }\n    \n    @MainActor\n    func markAsUnreadIfNeeded(\n        shouldMarkAsUnread: Bool,\n        targetPeerID: PeerID,\n        key: Data?,\n        isRecentMessage: Bool,\n        senderNickname: String,\n        messageContent: String\n    ) {\n        guard shouldMarkAsUnread else { return }\n        \n        unreadPrivateMessages.insert(targetPeerID)\n        if let key,\n           let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID,\n           ephemeralPeerID != targetPeerID {\n            unreadPrivateMessages.insert(ephemeralPeerID)\n        }\n        if isRecentMessage {\n            NotificationService.shared.sendPrivateMessageNotification(\n                from: senderNickname,\n                message: messageContent,\n                peerID: targetPeerID\n            )\n        }\n    }\n    \n    @MainActor\n    func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {\n        // Parse the message format: \"[FAVORITED]:npub...\" or \"[UNFAVORITED]:npub...\"\n        let isFavorite = content.hasPrefix(\"[FAVORITED]\")\n        let parts = content.split(separator: \":\")\n        \n        // Extract Nostr public key if included\n        var nostrPubkey: String? = nil\n        if parts.count > 1 {\n            nostrPubkey = String(parts[1])\n            SecureLogger.info(\"📝 Received Nostr npub in favorite notification: \\(nostrPubkey ?? \"none\")\", category: .session)\n        }\n        \n        // Get the noise public key for this peer\n        let noiseKey = peerID.noiseKey ?? unifiedPeerService.getPeer(by: peerID)?.noisePublicKey\n        \n        guard let finalNoiseKey = noiseKey else {\n            SecureLogger.warning(\"⚠️ Cannot get Noise key for peer \\(peerID)\", category: .session)\n            return\n        }\n        // Determine prior state to avoid duplicate system messages on repeated notifications\n        let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false\n\n        // Update the favorite relationship (idempotent storage)\n        FavoritesPersistenceService.shared.updatePeerFavoritedUs(\n            peerNoisePublicKey: finalNoiseKey,\n            favorited: isFavorite,\n            peerNickname: senderNickname,\n            peerNostrPublicKey: nostrPubkey\n        )\n\n        // If they favorited us and provided their Nostr key, ensure it's stored (log only)\n        if isFavorite && nostrPubkey != nil {\n            SecureLogger.info(\"💾 Storing Nostr key association for \\(senderNickname): \\(nostrPubkey!.prefix(16))...\", category: .session)\n        }\n\n        // Only show a system message when the state changes, and only in mesh\n        if prior != isFavorite {\n            let action = isFavorite ? \"favorited\" : \"unfavorited\"\n            addMeshOnlySystemMessage(\"\\(senderNickname) \\(action) you\")\n        }\n    }\n    \n    /// Process action messages (hugs, slaps) into system messages\n    func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {\n        let isActionMessage = message.content.hasPrefix(\"* \") && message.content.hasSuffix(\" *\") &&\n                              (message.content.contains(\"🫂\") || message.content.contains(\"🐟\") || \n                               message.content.contains(\"took a screenshot\"))\n        \n        if isActionMessage {\n            return BitchatMessage(\n                id: message.id,\n                sender: \"system\",\n                content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper\n                timestamp: message.timestamp,\n                isRelay: message.isRelay,\n                originalSender: message.originalSender,\n                isPrivate: message.isPrivate,\n                recipientNickname: message.recipientNickname,\n                senderPeerID: message.senderPeerID,\n                mentions: message.mentions,\n                deliveryStatus: message.deliveryStatus\n            )\n        }\n        return message\n    }\n    \n    /// Migrate private chats when peer reconnects with new ID\n    @MainActor\n    func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {\n        let currentFingerprint = getFingerprint(for: peerID)\n        \n        if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true {\n            var migratedMessages: [BitchatMessage] = []\n            var oldPeerIDsToRemove: [PeerID] = []\n            \n            // Only migrate messages from the last 24 hours to prevent old messages from flooding\n            let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)\n            \n            for (oldPeerID, messages) in privateChats {\n                if oldPeerID != peerID {\n                    let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID]\n                    \n                    // Filter messages to only recent ones\n                    let recentMessages = messages.filter { $0.timestamp > cutoffTime }\n                    \n                    // Skip if no recent messages\n                    guard !recentMessages.isEmpty else { continue }\n                    \n                    // Check fingerprint match first (most reliable)\n                    if let currentFp = currentFingerprint,\n                       let oldFp = oldFingerprint,\n                       currentFp == oldFp {\n                        migratedMessages.append(contentsOf: recentMessages)\n                        \n                        // Only remove old peer ID if we migrated ALL its messages\n                        if recentMessages.count == messages.count {\n                            oldPeerIDsToRemove.append(oldPeerID)\n                        } else {\n                            // Keep old messages in original location but don't show in UI\n                            SecureLogger.info(\"📦 Partially migrating \\(recentMessages.count) of \\(messages.count) messages from \\(oldPeerID)\", category: .session)\n                        }\n                        \n                        SecureLogger.info(\"📦 Migrating \\(recentMessages.count) recent messages from old peer ID \\(oldPeerID) to \\(peerID) (fingerprint match)\", category: .session)\n                    } else if currentFingerprint == nil || oldFingerprint == nil {\n                        // Check if this chat contains messages with this sender by nickname\n                        let isRelevantChat = recentMessages.contains { msg in\n                            (msg.sender == senderNickname && msg.sender != nickname) ||\n                            (msg.sender == nickname && msg.recipientNickname == senderNickname)\n                        }\n                        \n                        if isRelevantChat {\n                            migratedMessages.append(contentsOf: recentMessages)\n                            \n                            // Only remove if all messages were migrated\n                            if recentMessages.count == messages.count {\n                                oldPeerIDsToRemove.append(oldPeerID)\n                            }\n                            \n                            SecureLogger.warning(\"📦 Migrating \\(recentMessages.count) recent messages from old peer ID \\(oldPeerID) to \\(peerID) (nickname match)\", category: .session)\n                        }\n                    }\n                }\n            }\n            \n            // Remove old peer ID entries\n            if !oldPeerIDsToRemove.isEmpty {\n                // Track if we need to update selectedPrivateChatPeer\n                let needsSelectedUpdate = oldPeerIDsToRemove.contains { selectedPrivateChatPeer == $0 }\n                \n                for oldID in oldPeerIDsToRemove {\n                    privateChats.removeValue(forKey: oldID)\n                    unreadPrivateMessages.remove(oldID)\n                    \n                    // Also clean up fingerprint mapping\n                    if peerIDToPublicKeyFingerprint[oldID] != nil {\n                        peerIDToPublicKeyFingerprint.removeValue(forKey: oldID)\n                    }\n                }\n                \n                if needsSelectedUpdate {\n                    selectedPrivateChatPeer = peerID\n                }\n            }\n            \n            // Add migrated messages to new peer ID\n            if !migratedMessages.isEmpty {\n                if privateChats[peerID] == nil {\n                    privateChats[peerID] = []\n                }\n                privateChats[peerID]?.append(contentsOf: migratedMessages)\n                \n                // Sort by timestamp\n                privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }\n                \n                // De-duplicate just in case\n                privateChatManager.sanitizeChat(for: peerID)\n                \n                objectWillChange.send()\n            }\n        }\n    }\n    \n    @MainActor\n    func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {\n        // Handle both ephemeral peer IDs and Noise key hex strings\n        var noiseKey: Data?\n        \n        // First check if peerID is a hex-encoded Noise key\n        if let hexKey = Data(hexString: peerID.id) {\n            noiseKey = hexKey\n        } else {\n            // It's an ephemeral peer ID, get the Noise key from UnifiedPeerService\n            if let peer = unifiedPeerService.getPeer(by: peerID) {\n                noiseKey = peer.noisePublicKey\n            }\n        }\n        \n        // Try mesh first for connected peers\n        if meshService.isPeerConnected(peerID) {\n            messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)\n            SecureLogger.debug(\"📤 Sent favorite notification via BLE to \\(peerID)\", category: .session)\n        } else if let key = noiseKey {\n            // Send via Nostr for offline peers (using router)\n            messageRouter.sendFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite)\n        } else {\n            SecureLogger.warning(\"⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey\", category: .session)\n        }\n    }\n\n    /// Check if a message should be blocked based on sender\n    @MainActor\n    func isMessageBlocked(_ message: BitchatMessage) -> Bool {\n        if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) {\n            // Check mesh/known peers first\n            if isPeerBlocked(peerID) { return true }\n            // Check geohash (Nostr) blocks using mapping to full pubkey\n            if peerID.isGeoChat || peerID.isGeoDM {\n                if let full = nostrKeyMapping[peerID]?.lowercased() {\n                    if identityManager.isNostrBlocked(pubkeyHexLowercased: full) { return true }\n                }\n            }\n            return false\n        }\n        return false\n    }\n}\n"
  },
  {
    "path": "bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift",
    "content": "//\n// ChatViewModel+Tor.swift\n// bitchat\n//\n// Tor lifecycle handling for ChatViewModel\n//\n\nimport Foundation\nimport Combine\nimport Tor\n\nextension ChatViewModel {\n    \n    // MARK: - Tor notifications\n    \n    @objc func handleTorWillStart() {\n        Task { @MainActor in\n            if !self.torStatusAnnounced && TorManager.shared.torEnforced {\n                self.torStatusAnnounced = true\n                // Post only in geohash channels (queue if not active)\n                self.addGeohashOnlySystemMessage(\n                    String(localized: \"system.tor.starting\", comment: \"System message when Tor is starting\")\n                )\n            }\n        }\n    }\n\n    @objc func handleTorWillRestart() {\n        Task { @MainActor in\n            self.torRestartPending = true\n            // Post only in geohash channels (queue if not active)\n            self.addGeohashOnlySystemMessage(\n                String(localized: \"system.tor.restarting\", comment: \"System message when Tor is restarting\")\n            )\n        }\n    }\n\n    @objc func handleTorDidBecomeReady() {\n        Task { @MainActor in\n            // Only announce \"restarted\" if we actually restarted this session\n            if self.torRestartPending {\n                // Post only in geohash channels (queue if not active)\n                self.addGeohashOnlySystemMessage(\n                    String(localized: \"system.tor.restarted\", comment: \"System message when Tor has restarted\")\n                )\n                self.torRestartPending = false\n            } else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced {\n                // Initial start completed\n                self.addGeohashOnlySystemMessage(\n                    String(localized: \"system.tor.started\", comment: \"System message when Tor has started\")\n                )\n                self.torInitialReadyAnnounced = true\n            }\n        }\n    }\n\n    @objc func handleTorPreferenceChanged(_ notification: Notification) {\n        Task { @MainActor in\n            self.torStatusAnnounced = false\n            self.torInitialReadyAnnounced = false\n            self.torRestartPending = false\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/ViewModels/Extensions/README.md",
    "content": "# ChatViewModel Extensions\n\nThis directory contains extensions to `ChatViewModel` to modularize its functionality.\n\n- `ChatViewModel+Tor.swift`: Handles Tor lifecycle events and notifications.\n- `ChatViewModel+PrivateChat.swift`: Manages private chat logic, media transfers (images, voice notes), and file handling.\n- `ChatViewModel+Nostr.swift`: Contains all logic related to Nostr integration, Geohash channels, and Nostr identity management.\n\nThe main `ChatViewModel.swift` retains core state, initialization, and coordination logic.\n"
  },
  {
    "path": "bitchat/ViewModels/GeoChannelCoordinator.swift",
    "content": "//\n// GeoChannelCoordinator.swift\n// bitchat\n//\n// Centralizes Combine wiring for location channel selection and sampling.\n//\n\nimport Combine\nimport Foundation\nimport Tor\n\n@MainActor\nfinal class GeoChannelCoordinator {\n    private let locationManager: LocationChannelManager\n    private let bookmarksStore: GeohashBookmarksStore\n    private let torManager: TorManager\n\n    private let onChannelSwitch: (ChannelID) -> Void\n    private let beginSampling: ([String]) -> Void\n    private let endSampling: () -> Void\n\n    private var cancellables = Set<AnyCancellable>()\n    private var regionalGeohashes: [String] = []\n    private var bookmarkedGeohashes: [String] = []\n\n    init(\n        locationManager: LocationChannelManager? = nil,\n        bookmarksStore: GeohashBookmarksStore? = nil,\n        torManager: TorManager? = nil,\n        onChannelSwitch: @escaping (ChannelID) -> Void,\n        beginSampling: @escaping ([String]) -> Void,\n        endSampling: @escaping () -> Void\n    ) {\n        self.locationManager = locationManager ?? Self.defaultLocationManager()\n        self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared\n        self.torManager = torManager ?? Self.defaultTorManager()\n        self.onChannelSwitch = onChannelSwitch\n        self.beginSampling = beginSampling\n        self.endSampling = endSampling\n\n        start()\n    }\n\n    func start() {\n        regionalGeohashes = locationManager.availableChannels.map { $0.geohash }\n        bookmarkedGeohashes = bookmarksStore.bookmarks\n\n        locationManager.$selectedChannel\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] channel in\n                guard let self else { return }\n                Task { @MainActor in\n                    self.onChannelSwitch(channel)\n                }\n            }\n            .store(in: &cancellables)\n\n        locationManager.$availableChannels\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] channels in\n                guard let self else { return }\n                self.regionalGeohashes = channels.map { $0.geohash }\n                self.updateSampling()\n            }\n            .store(in: &cancellables)\n\n        bookmarksStore.$bookmarks\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] bookmarks in\n                guard let self else { return }\n                self.bookmarkedGeohashes = bookmarks\n                self.updateSampling()\n            }\n            .store(in: &cancellables)\n\n        locationManager.$permissionState\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] state in\n                guard let self, state == .authorized else { return }\n                Task { @MainActor [weak self] in\n                    self?.locationManager.refreshChannels()\n                }\n            }\n            .store(in: &cancellables)\n\n        Task { @MainActor in\n            self.onChannelSwitch(self.locationManager.selectedChannel)\n        }\n        updateSampling()\n    }\n\n    private func updateSampling() {\n        let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))\n        Task { @MainActor in\n            guard !union.isEmpty else {\n                endSampling()\n                return\n            }\n            if torManager.isForeground() {\n                beginSampling(union)\n            } else {\n                endSampling()\n            }\n        }\n    }\n\n    func refreshSampling() {\n        updateSampling()\n    }\n    private static func defaultLocationManager() -> LocationChannelManager {\n        LocationChannelManager.shared\n    }\n\n    @MainActor\n    private static func defaultTorManager() -> TorManager {\n        TorManager.shared\n    }\n}\n"
  },
  {
    "path": "bitchat/ViewModels/MessageRateLimiter.swift",
    "content": "//\n// MessageRateLimiter.swift\n// bitchat\n//\n// Handles per-sender and per-content token buckets for public message intake.\n//\n\nimport Foundation\n\nstruct MessageRateLimiter {\n    private struct TokenBucket {\n        var capacity: Double\n        var tokens: Double\n        var refillPerSec: Double\n        var lastRefill: Date\n\n        mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {\n            let dt = now.timeIntervalSince(lastRefill)\n            if dt > 0 {\n                tokens = min(capacity, tokens + dt * refillPerSec)\n                lastRefill = now\n            }\n            if tokens >= cost {\n                tokens -= cost\n                return true\n            }\n            return false\n        }\n    }\n\n    private var senderBuckets: [String: TokenBucket] = [:]\n    private var contentBuckets: [String: TokenBucket] = [:]\n\n    private let senderCapacity: Double\n    private let senderRefill: Double\n    private let contentCapacity: Double\n    private let contentRefill: Double\n\n    init(\n        senderCapacity: Double,\n        senderRefillPerSec: Double,\n        contentCapacity: Double,\n        contentRefillPerSec: Double\n    ) {\n        self.senderCapacity = senderCapacity\n        self.senderRefill = senderRefillPerSec\n        self.contentCapacity = contentCapacity\n        self.contentRefill = contentRefillPerSec\n    }\n\n    mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {\n        var senderBucket = senderBuckets[senderKey] ?? TokenBucket(\n            capacity: senderCapacity,\n            tokens: senderCapacity,\n            refillPerSec: senderRefill,\n            lastRefill: now\n        )\n        let senderAllowed = senderBucket.allow(now: now)\n        senderBuckets[senderKey] = senderBucket\n\n        var contentBucket = contentBuckets[contentKey] ?? TokenBucket(\n            capacity: contentCapacity,\n            tokens: contentCapacity,\n            refillPerSec: contentRefill,\n            lastRefill: now\n        )\n        let contentAllowed = contentBucket.allow(now: now)\n        contentBuckets[contentKey] = contentBucket\n\n        return senderAllowed && contentAllowed\n    }\n\n    mutating func reset() {\n        senderBuckets.removeAll()\n        contentBuckets.removeAll()\n    }\n}\n"
  },
  {
    "path": "bitchat/ViewModels/MinimalDistancePalette.swift",
    "content": "//\n// MinimalDistancePalette.swift\n// bitchat\n//\n// Lightweight palette generator that keeps peer colors evenly spaced.\n//\n\nimport Foundation\nimport SwiftUI\n\nfinal class MinimalDistancePalette {\n    struct Config {\n        let slotCount: Int\n        let avoidCenterHue: Double\n        let avoidHueDelta: Double\n        let saturationLight: Double\n        let saturationDark: Double\n        let baseBrightnessLight: Double\n        let baseBrightnessDark: Double\n        let ringBrightnessDeltaLight: Double\n        let ringBrightnessDeltaDark: Double\n        let preferredBiasWeight: Double\n        let goldenStep: Int\n\n        init(\n            slotCount: Int,\n            avoidCenterHue: Double,\n            avoidHueDelta: Double,\n            saturationLight: Double,\n            saturationDark: Double,\n            baseBrightnessLight: Double,\n            baseBrightnessDark: Double,\n            ringBrightnessDeltaLight: Double,\n            ringBrightnessDeltaDark: Double,\n            preferredBiasWeight: Double = 0.05,\n            goldenStep: Int = 7\n        ) {\n            self.slotCount = slotCount\n            self.avoidCenterHue = avoidCenterHue\n            self.avoidHueDelta = avoidHueDelta\n            self.saturationLight = saturationLight\n            self.saturationDark = saturationDark\n            self.baseBrightnessLight = baseBrightnessLight\n            self.baseBrightnessDark = baseBrightnessDark\n            self.ringBrightnessDeltaLight = ringBrightnessDeltaLight\n            self.ringBrightnessDeltaDark = ringBrightnessDeltaDark\n            self.preferredBiasWeight = preferredBiasWeight\n            self.goldenStep = goldenStep\n        }\n    }\n\n    private struct Entry {\n        let slot: Int\n        let ring: Int\n        let hue: Double\n    }\n\n    private let config: Config\n    private var currentSeeds: [String: String] = [:]\n    private var entries: [String: Entry] = [:]\n    private var previousEntries: [String: Entry] = [:]\n\n    init(config: Config) {\n        self.config = config\n    }\n\n    @MainActor\n    func ensurePalette(for seeds: [String: String]) {\n        guard seeds != currentSeeds || entries.count != seeds.count else { return }\n        previousEntries = entries\n        currentSeeds = seeds\n        rebuildEntries()\n    }\n\n    @MainActor\n    func color(for identifier: String, isDark: Bool) -> Color? {\n        guard let entry = entries[identifier] else { return nil }\n        let saturation = isDark ? config.saturationDark : config.saturationLight\n        let baseBrightness = isDark ? config.baseBrightnessDark : config.baseBrightnessLight\n        let ringDelta = isDark ? config.ringBrightnessDeltaDark : config.ringBrightnessDeltaLight\n        let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(entry.ring)))\n        return Color(hue: entry.hue, saturation: saturation, brightness: brightness)\n    }\n\n    @MainActor\n    func reset() {\n        currentSeeds.removeAll()\n        entries.removeAll()\n        previousEntries.removeAll()\n    }\n\n    @MainActor\n    private func rebuildEntries() {\n        guard !currentSeeds.isEmpty else {\n            entries.removeAll()\n            return\n        }\n\n        let slotCount = max(8, config.slotCount)\n        var slots: [Double] = []\n        for idx in 0..<slotCount {\n            let hue = Double(idx) / Double(slotCount)\n            if abs(hue - config.avoidCenterHue) < config.avoidHueDelta {\n                continue\n            }\n            slots.append(hue)\n        }\n        if slots.isEmpty {\n            for idx in 0..<slotCount {\n                slots.append(Double(idx) / Double(slotCount))\n            }\n        }\n\n        func circularDistance(_ a: Double, _ b: Double) -> Double {\n            let diff = abs(a - b)\n            return diff > 0.5 ? 1.0 - diff : diff\n        }\n\n        let peerIDs = currentSeeds.keys.sorted()\n        let preferredIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peerIDs.map { id in\n            let seed = currentSeeds[id] ?? id\n            let hash = seed.djb2()\n            let index = Int(hash % UInt64(slots.count))\n            return (id, index)\n        })\n\n        var mapping: [String: Entry] = [:]\n        var usedSlots = Set<Int>()\n        var usedHues: [Double] = []\n\n        let prior = entries.isEmpty ? previousEntries : entries\n        for (id, entry) in prior {\n            guard currentSeeds.keys.contains(id), entry.slot < slots.count else { continue }\n            let hue = slots[entry.slot]\n            mapping[id] = Entry(slot: entry.slot, ring: entry.ring, hue: hue)\n            usedSlots.insert(entry.slot)\n            usedHues.append(hue)\n        }\n\n        let unassigned = peerIDs.filter { mapping[$0] == nil }\n        for id in unassigned {\n            let preferred = preferredIndex[id] ?? 0\n            if !usedSlots.contains(preferred), preferred < slots.count {\n                let hue = slots[preferred]\n                mapping[id] = Entry(slot: preferred, ring: 0, hue: hue)\n                usedSlots.insert(preferred)\n                usedHues.append(hue)\n                continue\n            }\n\n            var bestSlot: Int?\n            var bestScore = -Double.infinity\n            for slot in 0..<slots.count where !usedSlots.contains(slot) {\n                let hue = slots[slot]\n                let minDistance = usedHues.isEmpty ? 1.0 : usedHues.map { circularDistance(hue, $0) }.min() ?? 1.0\n                let bias = 1.0 - (Double((abs(slot - (preferredIndex[id] ?? 0)) % slots.count)) / Double(slots.count))\n                let score = minDistance + config.preferredBiasWeight * bias\n                if score > bestScore {\n                    bestScore = score\n                    bestSlot = slot\n                }\n            }\n\n            if let slot = bestSlot {\n                let hue = slots[slot]\n                mapping[id] = Entry(slot: slot, ring: 0, hue: hue)\n                usedSlots.insert(slot)\n                usedHues.append(hue)\n            }\n        }\n\n        let remaining = peerIDs.filter { mapping[$0] == nil }\n        if !remaining.isEmpty {\n            for (index, id) in remaining.enumerated() {\n                let preferred = preferredIndex[id] ?? 0\n                let slot = (preferred + index * config.goldenStep) % slots.count\n                let hue = slots[slot]\n                mapping[id] = Entry(slot: slot, ring: 1, hue: hue)\n            }\n        }\n\n        entries = mapping\n    }\n}\n\nextension MinimalDistancePalette.Config {\n    static let mesh = MinimalDistancePalette.Config(\n        slotCount: TransportConfig.uiPeerPaletteSlots,\n        avoidCenterHue: 30.0 / 360.0,\n        avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,\n        saturationLight: 0.70,\n        saturationDark: 0.80,\n        baseBrightnessLight: 0.45,\n        baseBrightnessDark: 0.75,\n        ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,\n        ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark\n    )\n\n    static let nostr = MinimalDistancePalette.Config(\n        slotCount: TransportConfig.uiPeerPaletteSlots,\n        avoidCenterHue: 30.0 / 360.0,\n        avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,\n        saturationLight: 0.70,\n        saturationDark: 0.80,\n        baseBrightnessLight: 0.45,\n        baseBrightnessDark: 0.75,\n        ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,\n        ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark\n    )\n}\n"
  },
  {
    "path": "bitchat/ViewModels/PublicMessagePipeline.swift",
    "content": "//\n// PublicMessagePipeline.swift\n// bitchat\n//\n// Handles batching and deduplication of public chat messages before surfacing them to the UI.\n//\n\nimport Foundation\n\n@MainActor\nprotocol PublicMessagePipelineDelegate: AnyObject {\n    func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage]\n    func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage])\n    func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String\n    func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date?\n    func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date)\n    func pipelineTrimMessages(_ pipeline: PublicMessagePipeline)\n    func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage)\n    func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool)\n}\n\n@MainActor\nfinal class PublicMessagePipeline {\n    weak var delegate: PublicMessagePipelineDelegate?\n\n    private var buffer: [BitchatMessage] = []\n    private var timer: Timer?\n    private let baseFlushInterval: TimeInterval\n    private var dynamicFlushInterval: TimeInterval\n    private var recentBatchSizes: [Int] = []\n    private let maxRecentBatchSamples: Int\n    private let dedupWindow: TimeInterval\n    private var activeChannel: ChannelID = .mesh\n\n    init(\n        baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval,\n        maxRecentBatchSamples: Int = 10,\n        dedupWindow: TimeInterval = 1.0\n    ) {\n        self.baseFlushInterval = baseFlushInterval\n        self.dynamicFlushInterval = baseFlushInterval\n        self.maxRecentBatchSamples = maxRecentBatchSamples\n        self.dedupWindow = dedupWindow\n    }\n\n    deinit {\n        timer?.invalidate()\n    }\n\n    func updateActiveChannel(_ channel: ChannelID) {\n        activeChannel = channel\n    }\n\n    func enqueue(_ message: BitchatMessage) {\n        buffer.append(message)\n        scheduleFlush()\n    }\n\n    func flushIfNeeded() {\n        flushBuffer()\n    }\n\n    func reset() {\n        timer?.invalidate()\n        timer = nil\n        buffer.removeAll(keepingCapacity: false)\n    }\n\n}\n\nprivate extension PublicMessagePipeline {\n    func scheduleFlush() {\n        guard timer == nil else { return }\n        timer = Timer.scheduledTimer(withTimeInterval: dynamicFlushInterval, repeats: false) { [weak self] _ in\n            guard let self else { return }\n            Task { @MainActor in\n                self.flushBuffer()\n            }\n        }\n    }\n\n    func flushBuffer() {\n        timer?.invalidate()\n        timer = nil\n        guard !buffer.isEmpty else { return }\n        guard let delegate = delegate else {\n            buffer.removeAll(keepingCapacity: false)\n            return\n        }\n\n        delegate.pipelineSetBatchingState(self, isBatching: true)\n\n        var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id })\n        var pending: [(message: BitchatMessage, contentKey: String)] = []\n        var batchContentLatest: [String: Date] = [:]\n\n        for message in buffer {\n            if existingIDs.contains(message.id) { continue }\n            let contentKey = delegate.pipeline(self, normalizeContent: message.content)\n            if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey),\n               abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {\n                continue\n            }\n            if let ts = batchContentLatest[contentKey],\n               abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {\n                continue\n            }\n            existingIDs.insert(message.id)\n            pending.append((message, contentKey))\n            batchContentLatest[contentKey] = message.timestamp\n        }\n\n        buffer.removeAll(keepingCapacity: true)\n        guard !pending.isEmpty else {\n            delegate.pipelineSetBatchingState(self, isBatching: false)\n            if !buffer.isEmpty { scheduleFlush() }\n            return\n        }\n\n        pending.sort { $0.message.timestamp < $1.message.timestamp }\n\n        var messages = delegate.pipelineCurrentMessages(self)\n        let threshold = lateInsertThreshold(for: activeChannel)\n        let lastTimestamp = messages.last?.timestamp ?? .distantPast\n\n        for item in pending {\n            let message = item.message\n            if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) {\n                let index = insertionIndex(for: message.timestamp, in: messages)\n                if index >= messages.count {\n                    messages.append(message)\n                } else {\n                    messages.insert(message, at: index)\n                }\n            } else {\n                messages.append(message)\n            }\n            delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp)\n        }\n\n        delegate.pipeline(self, setMessages: messages)\n        delegate.pipelineTrimMessages(self)\n\n        updateFlushInterval(withBatchSize: pending.count)\n\n        for item in pending {\n            delegate.pipelinePrewarmMessage(self, message: item.message)\n        }\n\n        delegate.pipelineSetBatchingState(self, isBatching: false)\n\n        if !buffer.isEmpty {\n            scheduleFlush()\n        }\n    }\n\n    func updateFlushInterval(withBatchSize size: Int) {\n        recentBatchSizes.append(size)\n        if recentBatchSizes.count > maxRecentBatchSamples {\n            recentBatchSizes.removeFirst(recentBatchSizes.count - maxRecentBatchSamples)\n        }\n        let avg = recentBatchSizes.isEmpty\n            ? 0.0\n            : Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)\n        dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval\n    }\n\n    func lateInsertThreshold(for channel: ChannelID) -> TimeInterval {\n        switch channel {\n        case .mesh:\n            return TransportConfig.uiLateInsertThreshold\n        case .location:\n            return TransportConfig.uiLateInsertThresholdGeo\n        }\n    }\n\n    func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> Int {\n        var low = 0\n        var high = messages.count\n        while low < high {\n            let mid = (low + high) / 2\n            if messages[mid].timestamp < timestamp {\n                low = mid + 1\n            } else {\n                high = mid\n            }\n        }\n        return low\n    }\n}\n"
  },
  {
    "path": "bitchat/ViewModels/PublicTimelineStore.swift",
    "content": "//\n// PublicTimelineStore.swift\n// bitchat\n//\n// Maintains mesh and geohash public timelines with simple caps and helpers.\n//\n\nimport Foundation\n\nstruct PublicTimelineStore {\n    private var meshTimeline: [BitchatMessage] = []\n    private var geohashTimelines: [String: [BitchatMessage]] = [:]\n    private var pendingGeohashSystemMessages: [String] = []\n\n    private let meshCap: Int\n    private let geohashCap: Int\n\n    init(meshCap: Int, geohashCap: Int) {\n        self.meshCap = meshCap\n        self.geohashCap = geohashCap\n    }\n\n    mutating func append(_ message: BitchatMessage, to channel: ChannelID) {\n        switch channel {\n        case .mesh:\n            guard !meshTimeline.contains(where: { $0.id == message.id }) else { return }\n            meshTimeline.append(message)\n            trimMeshTimelineIfNeeded()\n        case .location(let channel):\n            append(message, toGeohash: channel.geohash)\n        }\n    }\n\n    mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {\n        var timeline = geohashTimelines[geohash] ?? []\n        guard !timeline.contains(where: { $0.id == message.id }) else { return }\n        timeline.append(message)\n        trimGeohashTimelineIfNeeded(&timeline)\n        geohashTimelines[geohash] = timeline\n    }\n\n    /// Append message if absent, returning true when stored.\n    mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {\n        var timeline = geohashTimelines[geohash] ?? []\n        guard !timeline.contains(where: { $0.id == message.id }) else { return false }\n        timeline.append(message)\n        trimGeohashTimelineIfNeeded(&timeline)\n        geohashTimelines[geohash] = timeline\n        return true\n    }\n\n    mutating func messages(for channel: ChannelID) -> [BitchatMessage] {\n        switch channel {\n        case .mesh:\n            return meshTimeline\n        case .location(let channel):\n            let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []\n            geohashTimelines[channel.geohash] = cleaned\n            return cleaned\n        }\n    }\n\n    mutating func clear(channel: ChannelID) {\n        switch channel {\n        case .mesh:\n            meshTimeline.removeAll()\n        case .location(let channel):\n            geohashTimelines[channel.geohash] = []\n        }\n    }\n\n    @discardableResult\n    mutating func removeMessage(withID id: String) -> BitchatMessage? {\n        if let index = meshTimeline.firstIndex(where: { $0.id == id }) {\n            return meshTimeline.remove(at: index)\n        }\n\n        for key in Array(geohashTimelines.keys) {\n            var timeline = geohashTimelines[key] ?? []\n            if let index = timeline.firstIndex(where: { $0.id == id }) {\n                let removed = timeline.remove(at: index)\n                geohashTimelines[key] = timeline.isEmpty ? nil : timeline\n                return removed\n            }\n        }\n\n        return nil\n    }\n\n    mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {\n        var timeline = geohashTimelines[geohash] ?? []\n        timeline.removeAll(where: predicate)\n        geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline\n    }\n\n    mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {\n        var timeline = geohashTimelines[geohash] ?? []\n        transform(&timeline)\n        geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline\n    }\n\n    mutating func queueGeohashSystemMessage(_ content: String) {\n        pendingGeohashSystemMessages.append(content)\n    }\n\n    mutating func drainPendingGeohashSystemMessages() -> [String] {\n        defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }\n        return pendingGeohashSystemMessages\n    }\n\n    func geohashKeys() -> [String] {\n        Array(geohashTimelines.keys)\n    }\n\n    private mutating func trimMeshTimelineIfNeeded() {\n        guard meshTimeline.count > meshCap else { return }\n        meshTimeline = Array(meshTimeline.suffix(meshCap))\n    }\n\n    private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) {\n        guard timeline.count > geohashCap else { return }\n        timeline = Array(timeline.suffix(geohashCap))\n    }\n}\n"
  },
  {
    "path": "bitchat/Views/AppInfoView.swift",
    "content": "import SwiftUI\n\nstruct AppInfoView: View {\n    @Environment(\\.dismiss) var dismiss\n    @Environment(\\.colorScheme) var colorScheme\n    \n    private var backgroundColor: Color {\n        colorScheme == .dark ? Color.black : Color.white\n    }\n    \n    private var textColor: Color {\n        colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)\n    }\n    \n    private var secondaryTextColor: Color {\n        colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)\n    }\n    \n    // MARK: - Constants\n    private enum Strings {\n        static let appName: LocalizedStringKey = \"app_info.app_name\"\n        static let tagline: LocalizedStringKey = \"app_info.tagline\"\n\n        enum Features {\n            static let title: LocalizedStringKey = \"app_info.features.title\"\n            static let offlineComm = AppInfoFeatureInfo(\n                icon: \"wifi.slash\",\n                title: \"app_info.features.offline.title\",\n                description: \"app_info.features.offline.description\"\n            )\n            static let encryption = AppInfoFeatureInfo(\n                icon: \"lock.shield\",\n                title: \"app_info.features.encryption.title\",\n                description: \"app_info.features.encryption.description\"\n            )\n            static let extendedRange = AppInfoFeatureInfo(\n                icon: \"antenna.radiowaves.left.and.right\",\n                title: \"app_info.features.extended_range.title\",\n                description: \"app_info.features.extended_range.description\"\n            )\n            static let mentions = AppInfoFeatureInfo(\n                icon: \"at\",\n                title: \"app_info.features.mentions.title\",\n                description: \"app_info.features.mentions.description\"\n            )\n            static let favorites = AppInfoFeatureInfo(\n                icon: \"star.fill\",\n                title: \"app_info.features.favorites.title\",\n                description: \"app_info.features.favorites.description\"\n            )\n            static let geohash = AppInfoFeatureInfo(\n                icon: \"number\",\n                title: \"app_info.features.geohash.title\",\n                description: \"app_info.features.geohash.description\"\n            )\n        }\n\n        enum Privacy {\n            static let title: LocalizedStringKey = \"app_info.privacy.title\"\n            static let noTracking = AppInfoFeatureInfo(\n                icon: \"eye.slash\",\n                title: \"app_info.privacy.no_tracking.title\",\n                description: \"app_info.privacy.no_tracking.description\"\n            )\n            static let ephemeral = AppInfoFeatureInfo(\n                icon: \"shuffle\",\n                title: \"app_info.privacy.ephemeral.title\",\n                description: \"app_info.privacy.ephemeral.description\"\n            )\n            static let panic = AppInfoFeatureInfo(\n                icon: \"hand.raised.fill\",\n                title: \"app_info.privacy.panic.title\",\n                description: \"app_info.privacy.panic.description\"\n            )\n        }\n\n        enum HowToUse {\n            static let title: LocalizedStringKey = \"app_info.how_to_use.title\"\n            static let instructions: [LocalizedStringKey] = [\n                \"app_info.how_to_use.set_nickname\",\n                \"app_info.how_to_use.change_channels\",\n                \"app_info.how_to_use.open_sidebar\",\n                \"app_info.how_to_use.start_dm\",\n                \"app_info.how_to_use.clear_chat\",\n                \"app_info.how_to_use.commands\"\n            ]\n        }\n\n    }\n    \n    var body: some View {\n        #if os(macOS)\n        VStack(spacing: 0) {\n            // Custom header for macOS\n            HStack {\n                Spacer()\n                Button(\"app_info.done\") {\n                    dismiss()\n                }\n                .buttonStyle(.plain)\n                .foregroundColor(textColor)\n                .padding()\n            }\n            .background(backgroundColor.opacity(0.95))\n            \n            ScrollView {\n                infoContent\n            }\n            .background(backgroundColor)\n        }\n        .frame(width: 600, height: 700)\n        #else\n        NavigationView {\n            ScrollView {\n                infoContent\n            }\n            .background(backgroundColor)\n            .navigationBarTitleDisplayMode(.inline)\n            .toolbar {\n                ToolbarItem(placement: .navigationBarTrailing) {\n                    Button(action: { dismiss() }) {\n                        Image(systemName: \"xmark\")\n                            .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))\n                            .foregroundColor(textColor)\n                            .frame(width: 32, height: 32)\n                    }\n                    .buttonStyle(.plain)\n                    .accessibilityLabel(\"app_info.close\")\n                }\n            }\n        }\n        #endif\n    }\n    \n    @ViewBuilder\n    private var infoContent: some View {\n        VStack(alignment: .leading, spacing: 24) {\n            // Header\n            VStack(alignment: .center, spacing: 8) {\n                Text(Strings.appName)\n                    .font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced))\n                    .foregroundColor(textColor)\n                \n                Text(Strings.tagline)\n                    .font(.bitchatSystem(size: 16, design: .monospaced))\n                    .foregroundColor(secondaryTextColor)\n            }\n            .frame(maxWidth: .infinity)\n            .padding(.vertical)\n            \n            // How to Use\n            VStack(alignment: .leading, spacing: 16) {\n                SectionHeader(Strings.HowToUse.title)\n\n                VStack(alignment: .leading, spacing: 8) {\n                    ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \\.offset) { _, instruction in\n                        Text(instruction)\n                    }\n                }\n                .font(.bitchatSystem(size: 14, design: .monospaced))\n                .foregroundColor(textColor)\n            }\n\n            // Features\n            VStack(alignment: .leading, spacing: 16) {\n                SectionHeader(Strings.Features.title)\n\n                FeatureRow(info: Strings.Features.offlineComm)\n\n                FeatureRow(info: Strings.Features.encryption)\n\n                FeatureRow(info: Strings.Features.extendedRange)\n\n                FeatureRow(info: Strings.Features.favorites)\n\n                FeatureRow(info: Strings.Features.geohash)\n\n                FeatureRow(info: Strings.Features.mentions)\n            }\n\n            // Privacy\n            VStack(alignment: .leading, spacing: 16) {\n                SectionHeader(Strings.Privacy.title)\n\n                FeatureRow(info: Strings.Privacy.noTracking)\n\n                FeatureRow(info: Strings.Privacy.ephemeral)\n\n                FeatureRow(info: Strings.Privacy.panic)\n            }\n        }\n        .padding()\n    }\n}\n\nstruct AppInfoFeatureInfo {\n    let icon: String\n    let title: LocalizedStringKey\n    let description: LocalizedStringKey\n}\n\nstruct SectionHeader: View {\n    let title: LocalizedStringKey\n    @Environment(\\.colorScheme) var colorScheme\n    \n    private var textColor: Color {\n        colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)\n    }\n    \n    init(_ title: LocalizedStringKey) {\n        self.title = title\n    }\n    \n    var body: some View {\n        Text(title)\n            .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))\n            .foregroundColor(textColor)\n            .padding(.top, 8)\n    }\n}\n\nstruct FeatureRow: View {\n    let info: AppInfoFeatureInfo\n    @Environment(\\.colorScheme) var colorScheme\n    \n    private var textColor: Color {\n        colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)\n    }\n    \n    private var secondaryTextColor: Color {\n        colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)\n    }\n    \n    var body: some View {\n        HStack(alignment: .top, spacing: 12) {\n            Image(systemName: info.icon)\n                .font(.bitchatSystem(size: 20))\n                .foregroundColor(textColor)\n                .frame(width: 30)\n            \n            VStack(alignment: .leading, spacing: 4) {\n                Text(info.title)\n                    .font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))\n                    .foregroundColor(textColor)\n                \n                Text(info.description)\n                    .font(.bitchatSystem(size: 12, design: .monospaced))\n                    .foregroundColor(secondaryTextColor)\n                    .fixedSize(horizontal: false, vertical: true)\n            }\n            \n            Spacer()\n        }\n    }\n}\n\n#Preview(\"Default\") {\n    AppInfoView()\n}\n\n#Preview(\"Dynamic Type XXL\") {\n    AppInfoView()\n        .environment(\\.sizeCategory, .accessibilityExtraExtraExtraLarge)\n}\n\n#Preview(\"Dynamic Type XS\") {\n    AppInfoView()\n        .environment(\\.sizeCategory, .extraSmall)\n}\n"
  },
  {
    "path": "bitchat/Views/Components/CommandSuggestionsView.swift",
    "content": "//\n//  CommandSuggestionsView.swift\n//  bitchat\n//\n//  Created by Islam on 29/10/2025.\n//\n\nimport SwiftUI\n\nstruct CommandSuggestionsView: View {\n    @EnvironmentObject private var viewModel: ChatViewModel\n    @ObservedObject private var locationManager = LocationChannelManager.shared\n    \n    @Binding var messageText: String\n    \n    let textColor: Color\n    let backgroundColor: Color\n    let secondaryTextColor: Color\n    \n    private var filteredCommands: [CommandInfo] {\n        guard messageText.hasPrefix(\"/\") && !messageText.contains(\" \") else { return [] }\n        let isGeoPublic = locationManager.selectedChannel.isLocation\n        let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true\n        return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in\n            command.alias.starts(with: messageText.lowercased())\n        }\n    }\n    \n    var body: some View {\n        VStack(alignment: .leading, spacing: 0) {\n            ForEach(filteredCommands) { command in\n                Button {\n                    messageText = command.alias + \" \"\n                } label: {\n                    buttonRow(for: command)\n                }\n                .buttonStyle(.plain)\n                .background(Color.gray.opacity(0.1))\n            }\n        }\n        .background(backgroundColor)\n        .overlay(\n            RoundedRectangle(cornerRadius: 4)\n                .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)\n        )\n    }\n    \n    private func buttonRow(for command: CommandInfo) -> some View {\n        HStack {\n            Text(command.alias)\n                .font(.bitchatSystem(size: 11, design: .monospaced))\n                .foregroundColor(textColor)\n                .fontWeight(.medium)\n            \n            if let placeholder = command.placeholder {\n                Text(placeholder)\n                    .font(.bitchatSystem(size: 10, design: .monospaced))\n                    .foregroundColor(secondaryTextColor.opacity(0.8))\n            }\n\n            Spacer()\n            \n            Text(command.description)\n                .font(.bitchatSystem(size: 10, design: .monospaced))\n                .foregroundColor(secondaryTextColor)\n        }\n        .padding(.horizontal, 12)\n        .padding(.vertical, 3)\n        .frame(maxWidth: .infinity, alignment: .leading)\n    }\n}\n\n@available(iOS 17, macOS 14, *)\n#Preview {\n    @Previewable @State var messageText: String = \"/\"\n    let keychain = KeychainManager()\n    let viewModel = ChatViewModel(\n        keychain: keychain,\n        idBridge: NostrIdentityBridge(),\n        identityManager: SecureIdentityStateManager(keychain)\n    )\n    \n    CommandSuggestionsView(\n        messageText: $messageText,\n        textColor: .green,\n        backgroundColor: .primary,\n        secondaryTextColor: .secondary\n    )\n    .environmentObject(viewModel)\n}\n"
  },
  {
    "path": "bitchat/Views/Components/DeliveryStatusView.swift",
    "content": "//\n// DeliveryStatusView.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport SwiftUI\n\nstruct DeliveryStatusView: View {\n    @Environment(\\.colorScheme) private var colorScheme\n    let status: DeliveryStatus\n\n    // MARK: - Computed Properties\n    \n    private var textColor: Color {\n        colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)\n    }\n    \n    private var secondaryTextColor: Color {\n        colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)\n    }\n\n    private enum Strings {\n        static func delivered(to nickname: String) -> String {\n            String(\n                format: String(localized: \"content.delivery.delivered_to\", comment: \"Tooltip for delivered private messages\"),\n                locale: .current,\n                nickname\n            )\n        }\n\n        static func read(by nickname: String) -> String {\n            String(\n                format: String(localized: \"content.delivery.read_by\", comment: \"Tooltip for read private messages\"),\n                locale: .current,\n                nickname\n            )\n        }\n\n        static func failed(_ reason: String) -> String {\n            String(\n                format: String(localized: \"content.delivery.failed\", comment: \"Tooltip for failed message delivery\"),\n                locale: .current,\n                reason\n            )\n        }\n\n        static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {\n            String(\n                format: String(localized: \"content.delivery.delivered_members\", comment: \"Tooltip for partially delivered messages\"),\n                locale: .current,\n                reached,\n                total\n            )\n        }\n    }\n    \n    // MARK: - Body\n    \n    var body: some View {\n        switch status {\n        case .sending:\n            Image(systemName: \"circle\")\n                .font(.bitchatSystem(size: 10))\n                .foregroundColor(secondaryTextColor.opacity(0.6))\n            \n        case .sent:\n            Image(systemName: \"checkmark\")\n                .font(.bitchatSystem(size: 10))\n                .foregroundColor(secondaryTextColor.opacity(0.6))\n            \n        case .delivered(let nickname, _):\n            HStack(spacing: -2) {\n                Image(systemName: \"checkmark\")\n                    .font(.bitchatSystem(size: 10))\n                Image(systemName: \"checkmark\")\n                    .font(.bitchatSystem(size: 10))\n            }\n            .foregroundColor(textColor.opacity(0.8))\n            .help(Strings.delivered(to: nickname))\n            \n        case .read(let nickname, _):\n            HStack(spacing: -2) {\n                Image(systemName: \"checkmark\")\n                    .font(.bitchatSystem(size: 10, weight: .bold))\n                Image(systemName: \"checkmark\")\n                    .font(.bitchatSystem(size: 10, weight: .bold))\n            }\n            .foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0))  // Bright blue\n            .help(Strings.read(by: nickname))\n            \n        case .failed(let reason):\n            Image(systemName: \"exclamationmark.triangle\")\n                .font(.bitchatSystem(size: 10))\n                .foregroundColor(Color.red.opacity(0.8))\n                .help(Strings.failed(reason))\n            \n        case .partiallyDelivered(let reached, let total):\n            HStack(spacing: 1) {\n                Image(systemName: \"checkmark\")\n                    .font(.bitchatSystem(size: 10))\n                Text(verbatim: \"\\(reached)/\\(total)\")\n                    .font(.bitchatSystem(size: 10, design: .monospaced))\n            }\n            .foregroundColor(secondaryTextColor.opacity(0.6))\n            .help(Strings.deliveredToMembers(reached, total))\n        }\n    }\n}\n\n#Preview {\n    let statuses: [DeliveryStatus] = [\n        .sending,\n        .sent,\n        .delivered(to: \"John Doe\", at: Date()),\n        .read(by: \"Jane Doe\", at: Date()),\n        .failed(reason: \"Offline\"),\n        .partiallyDelivered(reached: 2, total: 5)\n    ]\n    \n    List {\n        ForEach(statuses, id: \\.self) { status in\n            HStack {\n                Text(status.displayText)\n                Spacer()\n                DeliveryStatusView(status: status)\n            }\n        }\n    }\n    .environment(\\.colorScheme, .light)\n\n    List {\n        ForEach(statuses, id: \\.self) { status in\n            HStack {\n                Text(status.displayText)\n                Spacer()\n                DeliveryStatusView(status: status)\n            }\n        }\n    }\n    .environment(\\.colorScheme, .dark)\n}\n"
  },
  {
    "path": "bitchat/Views/Components/PaymentChipView.swift",
    "content": "//\n// PaymentChipView.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport SwiftUI\n\nstruct PaymentChipView: View {\n    @Environment(\\.colorScheme) private var colorScheme\n    @Environment(\\.openURL) private var openURL\n    \n    enum PaymentType {\n        case cashu(String)\n        case lightning(String)\n\n        private static let cashuAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: \"-_\"))\n\n        private static func cashuURL(from link: String) -> URL? {\n            if let url = URL(string: link), url.scheme != nil {\n                return url\n            }\n            let enc = link.addingPercentEncoding(withAllowedCharacters: cashuAllowedCharacters) ?? link\n            return URL(string: \"cashu:\\(enc)\")\n        }\n\n        var url: URL? {\n            switch self {\n            case .cashu(let link):\n                return Self.cashuURL(from: link)\n            case .lightning(let link):\n                return URL(string: link)\n            }\n        }\n        \n        var emoji: String {\n            switch self {\n            case .cashu:        \"🥜\"\n            case .lightning:    \"⚡\"\n            }\n        }\n        \n        var label: String {\n            switch self {\n            case .cashu:\n                String(localized: \"content.payment.cashu\", comment: \"Label for Cashu payment chip\")\n            case .lightning:\n                String(localized: \"content.payment.lightning\", comment: \"Label for Lightning payment chip\")\n            }\n        }\n    }\n    \n    let paymentType: PaymentType\n    \n    private var fgColor: Color {\n        colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)\n    }\n    private var bgColor: Color {\n        colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)\n    }\n    private var border: Color { fgColor.opacity(0.25) }\n    \n    var body: some View {\n        Button {\n            #if os(iOS)\n            if let url = paymentType.url { openURL(url) }\n            #else\n            if let url = paymentType.url { NSWorkspace.shared.open(url) }\n            #endif\n        } label: {\n            HStack(spacing: 6) {\n                Text(paymentType.emoji)\n                Text(paymentType.label)\n                    .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))\n            }\n            .padding(.vertical, 6)\n            .padding(.horizontal, 12)\n            .background(\n                RoundedRectangle(cornerRadius: 12)\n                    .fill(bgColor)\n            )\n            .overlay(\n                RoundedRectangle(cornerRadius: 12)\n                    .stroke(border, lineWidth: 1)\n            )\n            .foregroundColor(fgColor)\n        }\n        .buttonStyle(.plain)\n    }\n}\n\n#Preview {\n    let cashuLink = \"https://example.com/cashu\"\n    let lightningLink = \"https://example.com/lightning\"\n    \n    List {\n        HStack {\n            PaymentChipView(paymentType: .cashu(cashuLink))\n            PaymentChipView(paymentType: .lightning(lightningLink))\n        }\n        .listRowSeparator(.hidden)\n        .listRowInsets(EdgeInsets())\n        .listRowBackground(EmptyView())\n    }\n    .environment(\\.colorScheme, .light)\n\n    List {\n        HStack {\n            PaymentChipView(paymentType: .cashu(cashuLink))\n            PaymentChipView(paymentType: .lightning(lightningLink))\n        }\n        .listRowSeparator(.hidden)\n        .listRowInsets(EdgeInsets())\n        .listRowBackground(EmptyView())\n    }\n    .environment(\\.colorScheme, .dark)\n}\n"
  },
  {
    "path": "bitchat/Views/Components/TextMessageView.swift",
    "content": "//\n// TextMessageView.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport SwiftUI\n\nstruct TextMessageView: View {\n    @Environment(\\.colorScheme) private var colorScheme: ColorScheme\n    @EnvironmentObject private var viewModel: ChatViewModel\n    \n    let message: BitchatMessage\n    @Binding var expandedMessageIDs: Set<String>\n    \n    var body: some View {\n        VStack(alignment: .leading, spacing: 0) {\n            // Precompute heavy token scans once per row\n            let cashuLinks = message.content.extractCashuLinks()\n            let lightningLinks = message.content.extractLightningLinks()\n            HStack(alignment: .top, spacing: 0) {\n                let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty\n                let isExpanded = expandedMessageIDs.contains(message.id)\n                Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))\n                    .fixedSize(horizontal: false, vertical: true)\n                    .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)\n                    .frame(maxWidth: .infinity, alignment: .leading)\n                \n                // Delivery status indicator for private messages\n                if message.isPrivate && message.sender == viewModel.nickname,\n                   let status = message.deliveryStatus {\n                    DeliveryStatusView(status: status)\n                        .padding(.leading, 4)\n                }\n            }\n            \n            // Expand/Collapse for very long messages\n            if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {\n                let isExpanded = expandedMessageIDs.contains(message.id)\n                let labelKey = isExpanded ? LocalizedStringKey(\"content.message.show_less\") : LocalizedStringKey(\"content.message.show_more\")\n                Button(labelKey) {\n                    if isExpanded { expandedMessageIDs.remove(message.id) }\n                    else { expandedMessageIDs.insert(message.id) }\n                }\n                .font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))\n                .foregroundColor(Color.blue)\n                .padding(.top, 4)\n            }\n\n            // Render payment chips (Lightning / Cashu) with rounded background\n            if !lightningLinks.isEmpty || !cashuLinks.isEmpty {\n                HStack(spacing: 8) {\n                    ForEach(lightningLinks, id: \\.self) { link in\n                        PaymentChipView(paymentType: .lightning(link))\n                    }\n                    ForEach(cashuLinks, id: \\.self) { link in\n                        PaymentChipView(paymentType: .cashu(link))\n                    }\n                }\n                .padding(.top, 6)\n                .padding(.leading, 2)\n            }\n        }\n    }\n}\n\n@available(macOS 14, iOS 17, *)\n#Preview {\n    @Previewable @State var ids: Set<String> = []\n    let keychain = PreviewKeychainManager()\n    \n    Group {\n        List {\n            TextMessageView(message: .preview, expandedMessageIDs: $ids)\n                .listRowSeparator(.hidden)\n                .listRowInsets(EdgeInsets())\n                .listRowBackground(EmptyView())\n        }\n        .environment(\\.colorScheme, .light)\n        \n        List {\n            TextMessageView(message: .preview, expandedMessageIDs: $ids)\n                .listRowSeparator(.hidden)\n                .listRowInsets(EdgeInsets())\n                .listRowBackground(EmptyView())\n        }\n        .environment(\\.colorScheme, .dark)\n    }\n    .environmentObject(\n        ChatViewModel(\n            keychain: keychain,\n            idBridge: NostrIdentityBridge(),\n            identityManager: SecureIdentityStateManager(keychain)\n        )\n    )\n}\n"
  },
  {
    "path": "bitchat/Views/ContentView.swift",
    "content": "//\n// ContentView.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport SwiftUI\n#if os(iOS)\nimport UIKit\n#endif\n#if os(macOS)\nimport AppKit\n#endif\nimport UniformTypeIdentifiers\nimport BitLogger\n\n// MARK: - Supporting Types\n\n//\n\n//\n\nprivate struct MessageDisplayItem: Identifiable {\n    let id: String\n    let message: BitchatMessage\n}\n\n/// On macOS 14+, disables the default system focus ring on TextFields.\n/// On earlier macOS versions and on iOS this is a no-op.\nprivate struct FocusEffectDisabledModifier: ViewModifier {\n    func body(content: Content) -> some View {\n        #if os(macOS)\n        if #available(macOS 14.0, *) {\n            content.focusEffectDisabled()\n        } else {\n            content\n        }\n        #else\n        content\n        #endif\n    }\n}\n\n// MARK: - Main Content View\n\nstruct ContentView: View {\n    // MARK: - Properties\n    \n    @EnvironmentObject var viewModel: ChatViewModel\n    @ObservedObject private var locationManager = LocationChannelManager.shared\n    @ObservedObject private var bookmarks = GeohashBookmarksStore.shared\n    @State private var messageText = \"\"\n    @FocusState private var isTextFieldFocused: Bool\n    @Environment(\\.colorScheme) var colorScheme\n    @Environment(\\.dismiss) private var dismiss\n    @Environment(\\.dynamicTypeSize) private var dynamicTypeSize\n    @State private var showSidebar = false\n    @State private var showAppInfo = false\n    @State private var showMessageActions = false\n    @State private var selectedMessageSender: String?\n    @State private var selectedMessageSenderID: PeerID?\n    @FocusState private var isNicknameFieldFocused: Bool\n    @State private var isAtBottomPublic: Bool = true\n    @State private var isAtBottomPrivate: Bool = true\n    @State private var lastScrollTime: Date = .distantPast\n    @State private var scrollThrottleTimer: Timer?\n    @State private var autocompleteDebounceTimer: Timer?\n    @State private var showLocationChannelsSheet = false\n    @State private var showVerifySheet = false\n    @State private var expandedMessageIDs: Set<String> = []\n    @State private var showLocationNotes = false\n    @State private var notesGeohash: String? = nil\n    @State private var imagePreviewURL: URL? = nil\n    @State private var recordingAlertMessage: String = \"\"\n    @State private var showRecordingAlert = false\n    @State private var isRecordingVoiceNote = false\n    @State private var isPreparingVoiceNote = false\n    @State private var recordingDuration: TimeInterval = 0\n    @State private var recordingTimer: Timer?\n    @State private var recordingStartDate: Date?\n#if os(iOS)\n    @State private var showImagePicker = false\n    @State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera\n#else\n    @State private var showMacImagePicker = false\n#endif\n    @ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44\n    @ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11\n    @ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12\n    // Timer-based refresh removed; use LocationChannelManager live updates instead\n    // Window sizes for rendering (infinite scroll up)\n    @State private var windowCountPublic: Int = 300\n    @State private var windowCountPrivate: [PeerID: Int] = [:]\n    \n    // MARK: - Computed Properties\n    \n    private var backgroundColor: Color {\n        colorScheme == .dark ? Color.black : Color.white\n    }\n\n    private var textColor: Color {\n        colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)\n    }\n\n    private var secondaryTextColor: Color {\n        colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)\n    }\n\n    private var headerLineLimit: Int? {\n        dynamicTypeSize.isAccessibilitySize ? 2 : 1\n    }\n\n    private var peopleSheetTitle: String {\n        String(localized: \"content.header.people\", comment: \"Title for the people list sheet\").lowercased()\n    }\n\n    private var peopleSheetSubtitle: String? {\n        switch locationManager.selectedChannel {\n        case .mesh:\n            return \"#mesh\"\n        case .location(let channel):\n            return \"#\\(channel.geohash.lowercased())\"\n        }\n    }\n\n    private var peopleSheetActiveCount: Int {\n        switch locationManager.selectedChannel {\n        case .mesh:\n            return viewModel.allPeers.filter { $0.peerID != viewModel.meshService.myPeerID }.count\n        case .location:\n            return viewModel.visibleGeohashPeople().count\n        }\n    }\n    \n    \n    private struct PrivateHeaderContext {\n        let headerPeerID: PeerID\n        let peer: BitchatPeer?\n        let displayName: String\n        let isNostrAvailable: Bool\n    }\n\n// MARK: - Body\n\n    var body: some View {\n        VStack(spacing: 0) {\n            mainHeaderView\n                .onAppear {\n                    viewModel.currentColorScheme = colorScheme\n                    #if os(macOS)\n                    // Focus message input on macOS launch, not nickname field\n                    DispatchQueue.main.async {\n                        isNicknameFieldFocused = false\n                        isTextFieldFocused = true\n                    }\n                    #endif\n                }\n                .onChange(of: colorScheme) { newValue in\n                    viewModel.currentColorScheme = newValue\n                }\n\n            Divider()\n\n            GeometryReader { geometry in\n                VStack(spacing: 0) {\n                    messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)\n                        .background(backgroundColor)\n                        .frame(maxWidth: .infinity, maxHeight: .infinity)\n                }\n                .frame(width: geometry.size.width, height: geometry.size.height)\n            }\n\n            Divider()\n\n            if viewModel.selectedPrivateChatPeer == nil {\n                inputView\n            }\n        }\n        .background(backgroundColor)\n        .foregroundColor(textColor)\n        #if os(macOS)\n        .frame(minWidth: 600, minHeight: 400)\n        #endif\n        .onChange(of: viewModel.selectedPrivateChatPeer) { newValue in\n            if newValue != nil {\n                showSidebar = true\n            }\n        }\n        .sheet(\n            isPresented: Binding(\n                get: { showSidebar || viewModel.selectedPrivateChatPeer != nil },\n                set: { isPresented in\n                    if !isPresented {\n                        showSidebar = false\n                        viewModel.endPrivateChat()\n                    }\n                }\n            )\n        ) {\n            peopleSheetView\n        }\n        .sheet(isPresented: $showAppInfo) {\n            AppInfoView()\n                .environmentObject(viewModel)\n                .onAppear { viewModel.isAppInfoPresented = true }\n                .onDisappear { viewModel.isAppInfoPresented = false }\n        }\n        .sheet(isPresented: Binding(\n            get: { viewModel.showingFingerprintFor != nil && !showSidebar && viewModel.selectedPrivateChatPeer == nil },\n            set: { _ in viewModel.showingFingerprintFor = nil }\n        )) {\n            if let peerID = viewModel.showingFingerprintFor {\n                FingerprintView(viewModel: viewModel, peerID: peerID)\n                    .environmentObject(viewModel)\n            }\n        }\n#if os(iOS)\n        // Only present image picker from main view when NOT in a sheet\n        .fullScreenCover(isPresented: Binding(\n            get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },\n            set: { newValue in\n                if !newValue {\n                    showImagePicker = false\n                }\n            }\n        )) {\n            ImagePickerView(sourceType: imagePickerSourceType) { image in\n                showImagePicker = false\n                if let image = image {\n                    Task {\n                        do {\n                            let processedURL = try ImageUtils.processImage(image)\n                            await MainActor.run {\n                                viewModel.sendImage(from: processedURL)\n                            }\n                        } catch {\n                            SecureLogger.error(\"Image processing failed: \\(error)\", category: .session)\n                        }\n                    }\n                }\n            }\n            .environmentObject(viewModel)\n            .ignoresSafeArea()\n        }\n#endif\n#if os(macOS)\n        // Only present Mac image picker from main view when NOT in a sheet\n        .sheet(isPresented: Binding(\n            get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },\n            set: { newValue in\n                if !newValue {\n                    showMacImagePicker = false\n                }\n            }\n        )) {\n            MacImagePickerView { url in\n                showMacImagePicker = false\n                if let url = url {\n                    Task {\n                        do {\n                            let processedURL = try ImageUtils.processImage(at: url)\n                            await MainActor.run {\n                                viewModel.sendImage(from: processedURL)\n                            }\n                        } catch {\n                            SecureLogger.error(\"Image processing failed: \\(error)\", category: .session)\n                        }\n                    }\n                }\n            }\n            .environmentObject(viewModel)\n        }\n#endif\n        .sheet(isPresented: Binding(\n            get: { imagePreviewURL != nil },\n            set: { presenting in if !presenting { imagePreviewURL = nil } }\n        )) {\n            if let url = imagePreviewURL {\n                ImagePreviewView(url: url)\n                    .environmentObject(viewModel)\n            }\n        }\n        .alert(\"Recording Error\", isPresented: $showRecordingAlert, actions: {\n            Button(\"OK\", role: .cancel) {}\n        }, message: {\n            Text(recordingAlertMessage)\n        })\n        .confirmationDialog(\n            selectedMessageSender.map { \"@\\($0)\" } ?? String(localized: \"content.actions.title\", comment: \"Fallback title for the message action sheet\"),\n            isPresented: $showMessageActions,\n            titleVisibility: .visible\n        ) {\n            Button(\"content.actions.mention\") {\n                if let sender = selectedMessageSender {\n                    // Pre-fill the input with an @mention and focus the field\n                    messageText = \"@\\(sender) \"\n                    isTextFieldFocused = true\n                }\n            }\n\n            Button(\"content.actions.direct_message\") {\n                if let peerID = selectedMessageSenderID {\n                    if peerID.isGeoChat {\n                        if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {\n                            viewModel.startGeohashDM(withPubkeyHex: full)\n                        }\n                    } else {\n                        viewModel.startPrivateChat(with: peerID)\n                    }\n                    withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {\n                        showSidebar = true\n                    }\n                }\n            }\n\n            Button(\"content.actions.hug\") {\n                if let sender = selectedMessageSender {\n                    viewModel.sendMessage(\"/hug @\\(sender)\")\n                }\n            }\n\n            Button(\"content.actions.slap\") {\n                if let sender = selectedMessageSender {\n                    viewModel.sendMessage(\"/slap @\\(sender)\")\n                }\n            }\n\n            Button(\"content.actions.block\", role: .destructive) {\n                // Prefer direct geohash block when we have a Nostr sender ID\n                if let peerID = selectedMessageSenderID, peerID.isGeoChat,\n                   let full = viewModel.fullNostrHex(forSenderPeerID: peerID),\n                   let sender = selectedMessageSender {\n                    viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)\n                } else if let sender = selectedMessageSender {\n                    viewModel.sendMessage(\"/block \\(sender)\")\n                }\n            }\n\n            Button(\"common.cancel\", role: .cancel) {}\n        }\n        .alert(\"content.alert.bluetooth_required.title\", isPresented: $viewModel.showBluetoothAlert) {\n            Button(\"content.alert.bluetooth_required.settings\") {\n                #if os(iOS)\n                if let url = URL(string: UIApplication.openSettingsURLString) {\n                    UIApplication.shared.open(url)\n                }\n                #endif\n            }\n            Button(\"common.ok\", role: .cancel) {}\n        } message: {\n            Text(viewModel.bluetoothAlertMessage)\n        }\n        .onDisappear {\n            // Clean up timers\n            scrollThrottleTimer?.invalidate()\n            autocompleteDebounceTimer?.invalidate()\n        }\n    }\n    \n    // MARK: - Message List View\n    \n    private func messagesView(privatePeer: PeerID?, isAtBottom: Binding<Bool>) -> some View {\n        let messages: [BitchatMessage] = {\n            if let peerID = privatePeer {\n                return viewModel.getPrivateChatMessages(for: peerID)\n            }\n            return viewModel.messages\n        }()\n\n        let currentWindowCount: Int = {\n            if let peer = privatePeer {\n                return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate\n            }\n            return windowCountPublic\n        }()\n\n        let windowedMessages: [BitchatMessage] = Array(messages.suffix(currentWindowCount))\n\n        let contextKey: String = {\n            if let peer = privatePeer { return \"dm:\\(peer)\" }\n            switch locationManager.selectedChannel {\n            case .mesh: return \"mesh\"\n            case .location(let ch): return \"geo:\\(ch.geohash)\"\n            }\n        }()\n\n        let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in\n            let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !trimmed.isEmpty else { return nil }\n            return MessageDisplayItem(id: \"\\(contextKey)|\\(message.id)\", message: message)\n        }\n\n        return ScrollViewReader { proxy in\n            ScrollView {\n                LazyVStack(alignment: .leading, spacing: 0) {\n                    ForEach(messageItems) { item in\n                        let message = item.message\n                        messageRow(for: message)\n                            .onAppear {\n                                if message.id == windowedMessages.last?.id {\n                                    isAtBottom.wrappedValue = true\n                                }\n                                if message.id == windowedMessages.first?.id,\n                                   messages.count > windowedMessages.count {\n                                    expandWindow(\n                                        ifNeededFor: message,\n                                        allMessages: messages,\n                                        privatePeer: privatePeer,\n                                        proxy: proxy\n                                    )\n                                }\n                            }\n                            .onDisappear {\n                                if message.id == windowedMessages.last?.id {\n                                    isAtBottom.wrappedValue = false\n                                }\n                            }\n                            .contentShape(Rectangle())\n                            .onTapGesture {\n                                if message.sender != \"system\" {\n                                    messageText = \"@\\(message.sender) \"\n                                    isTextFieldFocused = true\n                                }\n                            }\n                            .contextMenu {\n                                Button(\"content.message.copy\") {\n                                    #if os(iOS)\n                                    UIPasteboard.general.string = message.content\n                                    #else\n                                    let pb = NSPasteboard.general\n                                    pb.clearContents()\n                                    pb.setString(message.content, forType: .string)\n                                    #endif\n                                }\n                            }\n                            .padding(.horizontal, 12)\n                            .padding(.vertical, 1)\n                    }\n                }\n                .transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }\n                .padding(.vertical, 2)\n            }\n            .background(backgroundColor)\n            .onOpenURL { handleOpenURL($0) }\n            .onTapGesture(count: 3) {\n                viewModel.sendMessage(\"/clear\")\n            }\n            .onAppear {\n                scrollToBottom(on: proxy, privatePeer: privatePeer, isAtBottom: isAtBottom)\n            }\n            .onChange(of: privatePeer) { _ in\n                scrollToBottom(on: proxy, privatePeer: privatePeer, isAtBottom: isAtBottom)\n            }\n            .onChange(of: viewModel.messages.count) { _ in\n                if privatePeer == nil && !viewModel.messages.isEmpty {\n                    // If the newest message is from me, always scroll to bottom\n                    let lastMsg = viewModel.messages.last!\n                    let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + \"#\")\n                    if !isFromSelf {\n                        // Only autoscroll when user is at/near bottom\n                        guard isAtBottom.wrappedValue else { return }\n                    } else {\n                        // Ensure we consider ourselves at bottom for subsequent messages\n                        isAtBottom.wrappedValue = true\n                    }\n                    // Throttle scroll animations to prevent excessive UI updates\n                    let now = Date()\n                    if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {\n                        // Immediate scroll if enough time has passed\n                        lastScrollTime = now\n                        let contextKey: String = {\n                            switch locationManager.selectedChannel {\n                            case .mesh: return \"mesh\"\n                            case .location(let ch): return \"geo:\\(ch.geohash)\"\n                            }\n                        }()\n                        let count = windowCountPublic\n                        let target = viewModel.messages.suffix(count).last.map { \"\\(contextKey)|\\($0.id)\" }\n                        DispatchQueue.main.async {\n                            if let target = target { proxy.scrollTo(target, anchor: .bottom) }\n                        }\n                    } else {\n                        // Schedule a delayed scroll\n                        scrollThrottleTimer?.invalidate()\n                        scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { [weak viewModel] _ in\n                            Task { @MainActor in\n                                lastScrollTime = Date()\n                                let contextKey: String = {\n                                    switch locationManager.selectedChannel {\n                                    case .mesh: return \"mesh\"\n                                    case .location(let ch): return \"geo:\\(ch.geohash)\"\n                                    }\n                                }()\n                                let count = windowCountPublic\n                                let target = viewModel?.messages.suffix(count).last.map { \"\\(contextKey)|\\($0.id)\" }\n                                if let target = target { proxy.scrollTo(target, anchor: .bottom) }\n                            }\n                        }\n                    }\n                }\n            }\n            .onChange(of: viewModel.privateChats) { _ in\n                if let peerID = privatePeer,\n                   let messages = viewModel.privateChats[peerID],\n                   !messages.isEmpty {\n                    // If the newest private message is from me, always scroll\n                    let lastMsg = messages.last!\n                    let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + \"#\")\n                    if !isFromSelf {\n                        // Only autoscroll when user is at/near bottom\n                        guard isAtBottom.wrappedValue else { return }\n                    } else {\n                        isAtBottom.wrappedValue = true\n                    }\n                    // Same throttling for private chats\n                    let now = Date()\n                    if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {\n                        lastScrollTime = now\n                        let contextKey = \"dm:\\(peerID)\"\n                        let count = windowCountPrivate[peerID] ?? 300\n                        let target = messages.suffix(count).last.map { \"\\(contextKey)|\\($0.id)\" }\n                        DispatchQueue.main.async {\n                            if let target = target { proxy.scrollTo(target, anchor: .bottom) }\n                        }\n                    } else {\n                        scrollThrottleTimer?.invalidate()\n                        scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in\n                            lastScrollTime = Date()\n                            let contextKey = \"dm:\\(peerID)\"\n                            let count = windowCountPrivate[peerID] ?? 300\n                            let target = messages.suffix(count).last.map { \"\\(contextKey)|\\($0.id)\" }\n                            DispatchQueue.main.async {\n                                if let target = target { proxy.scrollTo(target, anchor: .bottom) }\n                            }\n                        }\n                    }\n                }\n            }\n            .onChange(of: locationManager.selectedChannel) { newChannel in\n                // When switching to a new geohash channel, scroll to the bottom\n                guard privatePeer == nil else { return }\n                switch newChannel {\n                case .mesh:\n                    break\n                case .location(let ch):\n                    // Reset window size\n                    windowCountPublic = TransportConfig.uiWindowInitialCountPublic\n                    let contextKey = \"geo:\\(ch.geohash)\"\n                    let last = viewModel.messages.suffix(windowCountPublic).last?.id\n                    let target = last.map { \"\\(contextKey)|\\($0)\" }\n                    isAtBottom.wrappedValue = true\n                    DispatchQueue.main.async {\n                        if let target = target { proxy.scrollTo(target, anchor: .bottom) }\n                    }\n                }\n            }\n            .onAppear {\n                // Also check when view appears\n                if let peerID = privatePeer {\n                    // Try multiple times to ensure read receipts are sent\n                    viewModel.markPrivateMessagesAsRead(from: peerID)\n                    \n                    DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {\n                        viewModel.markPrivateMessagesAsRead(from: peerID)\n                    }\n                    \n                    DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {\n                        viewModel.markPrivateMessagesAsRead(from: peerID)\n                    }\n                }\n            }\n        }\n        .environment(\\.openURL, OpenURLAction { url in\n            // Intercept custom cashu: links created in attributed text\n            if let scheme = url.scheme?.lowercased(), scheme == \"cashu\" || scheme == \"lightning\" {\n                #if os(iOS)\n                UIApplication.shared.open(url)\n                return .handled\n                #else\n                // On non-iOS platforms, let the system handle or ignore\n                return .systemAction\n                #endif\n            }\n            return .systemAction\n        })\n    }\n    \n    // MARK: - Input View\n\n    @ViewBuilder\n    private var inputView: some View {\n        VStack(alignment: .leading, spacing: 6) {\n            // @mentions autocomplete\n            if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {\n                VStack(alignment: .leading, spacing: 0) {\n                    ForEach(Array(viewModel.autocompleteSuggestions.prefix(4)), id: \\.self) { suggestion in\n                        Button(action: {\n                            _ = viewModel.completeNickname(suggestion, in: &messageText)\n                        }) {\n                            HStack {\n                                Text(suggestion)\n                                    .font(.bitchatSystem(size: 11, design: .monospaced))\n                                    .foregroundColor(textColor)\n                                    .fontWeight(.medium)\n                                Spacer()\n                            }\n                            .padding(.horizontal, 12)\n                            .padding(.vertical, 3)\n                            .frame(maxWidth: .infinity, alignment: .leading)\n                        }\n                        .buttonStyle(.plain)\n                        .background(Color.gray.opacity(0.1))\n                    }\n                }\n                .background(backgroundColor)\n                .overlay(\n                    RoundedRectangle(cornerRadius: 4)\n                        .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)\n                )\n                .padding(.horizontal, 12)\n            }\n\n            CommandSuggestionsView(\n                messageText: $messageText,\n                textColor: textColor,\n                backgroundColor: backgroundColor,\n                secondaryTextColor: secondaryTextColor\n            )\n\n            // Recording indicator\n            if isPreparingVoiceNote || isRecordingVoiceNote {\n                recordingIndicator\n            }\n\n            HStack(alignment: .center, spacing: 4) {\n                TextField(\n                    \"\",\n                    text: $messageText,\n                    prompt: Text(\n                        String(localized: \"content.input.message_placeholder\", comment: \"Placeholder shown in the chat composer\")\n                    )\n                    .foregroundColor(secondaryTextColor.opacity(0.6))\n                )\n                .textFieldStyle(.plain)\n                .font(.bitchatSystem(size: 15, design: .monospaced))\n                .foregroundColor(textColor)\n                .focused($isTextFieldFocused)\n                .autocorrectionDisabled(true)\n#if os(iOS)\n                .textInputAutocapitalization(.sentences)\n#endif\n                .submitLabel(.send)\n                .onSubmit { sendMessage() }\n                .padding(.vertical, 4)\n                .padding(.horizontal, 6)\n                .background(\n                    RoundedRectangle(cornerRadius: 14, style: .continuous)\n                        .fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7))\n                )\n                .modifier(FocusEffectDisabledModifier())\n                .frame(maxWidth: .infinity, alignment: .leading)\n                .onChange(of: messageText) { newValue in\n                    autocompleteDebounceTimer?.invalidate()\n                    autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak viewModel] _ in\n                        let cursorPosition = newValue.count\n                        Task { @MainActor in\n                            viewModel?.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)\n                        }\n                    }\n                }\n\n                HStack(alignment: .center, spacing: 4) {\n                    if shouldShowMediaControls {\n                        attachmentButton\n                    }\n\n                    sendOrMicButton\n                }\n            }\n        }\n        .padding(.horizontal, 6)\n        .padding(.top, 6)\n        .padding(.bottom, 8)\n        .background(backgroundColor.opacity(0.95))\n    }\n    \n    private func handleOpenURL(_ url: URL) {\n        guard url.scheme == \"bitchat\" else { return }\n        switch url.host {\n        case \"user\":\n            let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: \"/\"))\n            let peerID = PeerID(str: id.removingPercentEncoding ?? id)\n            selectedMessageSenderID = peerID\n\n            if peerID.isGeoDM || peerID.isGeoChat {\n                selectedMessageSender = viewModel.geohashDisplayName(for: peerID)\n            } else if let name = viewModel.meshService.peerNickname(peerID: peerID) {\n                selectedMessageSender = name\n            } else {\n                selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != \"system\" })?.sender\n            }\n\n            if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {\n                selectedMessageSender = nil\n                selectedMessageSenderID = nil\n            } else {\n                showMessageActions = true\n            }\n\n        case \"geohash\":\n            let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: \"/\")).lowercased()\n            let allowed = Set(\"0123456789bcdefghjkmnpqrstuvwxyz\")\n            guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }\n\n            func levelForLength(_ len: Int) -> GeohashChannelLevel {\n                switch len {\n                case 0...2: return .region\n                case 3...4: return .province\n                case 5: return .city\n                case 6: return .neighborhood\n                case 7: return .block\n                default: return .block\n                }\n            }\n\n            let level = levelForLength(gh.count)\n            let channel = GeohashChannel(level: level, geohash: gh)\n\n            let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }\n            if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {\n                LocationChannelManager.shared.markTeleported(for: gh, true)\n            }\n            LocationChannelManager.shared.select(ChannelID.location(channel))\n\n        default:\n            return\n        }\n    }\n\n    private func scrollToBottom(on proxy: ScrollViewProxy,\n                                privatePeer: PeerID?,\n                                isAtBottom: Binding<Bool>) {\n        let targetID: String? = {\n            if let peer = privatePeer,\n               let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {\n                return \"dm:\\(peer)|\\(last)\"\n            }\n            let contextKey: String = {\n                switch locationManager.selectedChannel {\n                case .mesh: return \"mesh\"\n                case .location(let ch): return \"geo:\\(ch.geohash)\"\n                }\n            }()\n            if let last = viewModel.messages.suffix(300).last?.id {\n                return \"\\(contextKey)|\\(last)\"\n            }\n            return nil\n        }()\n\n        isAtBottom.wrappedValue = true\n\n        DispatchQueue.main.async {\n            if let targetID {\n                proxy.scrollTo(targetID, anchor: .bottom)\n            }\n        }\n\n        DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {\n            let secondTarget: String? = {\n                if let peer = privatePeer,\n                   let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {\n                    return \"dm:\\(peer)|\\(last)\"\n                }\n                let contextKey: String = {\n                    switch locationManager.selectedChannel {\n                    case .mesh: return \"mesh\"\n                    case .location(let ch): return \"geo:\\(ch.geohash)\"\n                    }\n                }()\n                if let last = viewModel.messages.suffix(300).last?.id {\n                    return \"\\(contextKey)|\\(last)\"\n                }\n                return nil\n            }()\n\n            if let secondTarget {\n                proxy.scrollTo(secondTarget, anchor: .bottom)\n            }\n        }\n    }\n    // MARK: - Actions\n    \n    private func sendMessage() {\n        let trimmed = trimmedMessageText\n        guard !trimmed.isEmpty else { return }\n\n        // Clear input immediately for instant feedback\n        messageText = \"\"\n\n        // Defer actual send to next runloop to avoid blocking\n        DispatchQueue.main.async {\n            self.viewModel.sendMessage(trimmed)\n        }\n    }\n    \n    // MARK: - Sheet Content\n    \n    private var peopleSheetView: some View {\n        NavigationStack {\n            Group {\n                if viewModel.selectedPrivateChatPeer != nil {\n                    privateChatSheetView\n                } else {\n                    peopleListSheetView\n                }\n            }\n            .navigationDestination(isPresented: Binding(\n                get: { viewModel.showingFingerprintFor != nil && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },\n                set: { isPresented in\n                    if !isPresented {\n                        viewModel.showingFingerprintFor = nil\n                    }\n                }\n            )) {\n                if let peerID = viewModel.showingFingerprintFor {\n                    FingerprintView(viewModel: viewModel, peerID: peerID)\n                        .environmentObject(viewModel)\n                }\n            }\n        }\n        .background(backgroundColor)\n        .foregroundColor(textColor)\n        #if os(macOS)\n        .frame(minWidth: 420, minHeight: 520)\n        #endif\n        // Present image picker from sheet context when IN a sheet (parent-child pattern)\n        #if os(iOS)\n        .fullScreenCover(isPresented: Binding(\n            get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },\n            set: { newValue in\n                if !newValue {\n                    showImagePicker = false\n                }\n            }\n        )) {\n            ImagePickerView(sourceType: imagePickerSourceType) { image in\n                showImagePicker = false\n                if let image = image {\n                    Task {\n                        do {\n                            let processedURL = try ImageUtils.processImage(image)\n                            await MainActor.run {\n                                viewModel.sendImage(from: processedURL)\n                            }\n                        } catch {\n                            SecureLogger.error(\"Image processing failed: \\(error)\", category: .session)\n                        }\n                    }\n                }\n            }\n            .environmentObject(viewModel)\n            .ignoresSafeArea()\n        }\n        #endif\n        #if os(macOS)\n        .sheet(isPresented: $showMacImagePicker) {\n            MacImagePickerView { url in\n                showMacImagePicker = false\n                if let url = url {\n                    Task {\n                        do {\n                            let processedURL = try ImageUtils.processImage(at: url)\n                            await MainActor.run {\n                                viewModel.sendImage(from: processedURL)\n                            }\n                        } catch {\n                            SecureLogger.error(\"Image processing failed: \\(error)\", category: .session)\n                        }\n                    }\n                }\n            }\n            .environmentObject(viewModel)\n        }\n        #endif\n    }\n    \n    // MARK: - People Sheet Views\n    \n    private var peopleListSheetView: some View {\n        VStack(spacing: 0) {\n            VStack(alignment: .leading, spacing: 8) {\n                HStack(spacing: 12) {\n                    Text(peopleSheetTitle)\n                        .font(.bitchatSystem(size: 18, design: .monospaced))\n                        .foregroundColor(textColor)\n                    Spacer()\n                    if case .mesh = locationManager.selectedChannel {\n                        Button(action: { showVerifySheet = true }) {\n                            Image(systemName: \"qrcode\")\n                                .font(.bitchatSystem(size: 14))\n                        }\n                        .buttonStyle(.plain)\n                        .help(\n                            String(localized: \"content.help.verification\", comment: \"Help text for verification button\")\n                        )\n                    }\n                    Button(action: {\n                        withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {\n                            dismiss()\n                            showSidebar = false\n                            showVerifySheet = false\n                            viewModel.endPrivateChat()\n                        }\n                    }) {\n                        Image(systemName: \"xmark\")\n                            .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))\n                            .frame(width: 32, height: 32)\n                    }\n                    .buttonStyle(.plain)\n                    .accessibilityLabel(\"Close\")\n                }\n                let activeText = String.localizedStringWithFormat(\n                    String(localized: \"%@ active\", comment: \"Count of active users in the people sheet\"),\n                    \"\\(peopleSheetActiveCount)\"\n                )\n\n                if let subtitle = peopleSheetSubtitle {\n                    let subtitleColor: Color = {\n                        switch locationManager.selectedChannel {\n                        case .mesh:\n                            return Color.blue\n                        case .location:\n                            return Color.green\n                        }\n                    }()\n                    HStack(spacing: 6) {\n                        Text(subtitle)\n                            .foregroundColor(subtitleColor)\n                        Text(activeText)\n                            .foregroundColor(.secondary)\n                    }\n                    .font(.bitchatSystem(size: 12, design: .monospaced))\n                } else {\n                    Text(activeText)\n                        .font(.bitchatSystem(size: 12, design: .monospaced))\n                        .foregroundColor(.secondary)\n                }\n            }\n            .padding(.horizontal, 16)\n            .padding(.top, 16)\n            .padding(.bottom, 12)\n            .background(backgroundColor)\n            \n            ScrollView {\n                VStack(alignment: .leading, spacing: 6) {\n                    if case .location = locationManager.selectedChannel {\n                        GeohashPeopleList(\n                            viewModel: viewModel,\n                            textColor: textColor,\n                            secondaryTextColor: secondaryTextColor,\n                            onTapPerson: {\n                                showSidebar = true\n                            }\n                        )\n                    } else {\n                        MeshPeerList(\n                            viewModel: viewModel,\n                            textColor: textColor,\n                            secondaryTextColor: secondaryTextColor,\n                            onTapPeer: { peerID in\n                                viewModel.startPrivateChat(with: peerID)\n                                showSidebar = true\n                            },\n                            onToggleFavorite: { peerID in\n                                viewModel.toggleFavorite(peerID: peerID)\n                            },\n                            onShowFingerprint: { peerID in\n                                viewModel.showFingerprint(for: peerID)\n                            }\n                        )\n                    }\n                }\n                .padding(.top, 4)\n                .id(viewModel.allPeers.map { \"\\($0.peerID)-\\($0.isConnected)\" }.joined())\n            }\n        }\n    }\n    \n    // MARK: - View Components\n\n    private var privateChatSheetView: some View {\n        VStack(spacing: 0) {\n            if let privatePeerID = viewModel.selectedPrivateChatPeer {\n                let headerContext = makePrivateHeaderContext(for: privatePeerID)\n\n                HStack(spacing: 12) {\n                    Button(action: {\n                        withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {\n                            viewModel.endPrivateChat()\n                        }\n                    }) {\n                        Image(systemName: \"chevron.left\")\n                            .font(.bitchatSystem(size: 12))\n                            .foregroundColor(textColor)\n                            .frame(width: 44, height: 44)\n                            .contentShape(Rectangle())\n                    }\n                    .buttonStyle(.plain)\n                    .accessibilityLabel(\n                        String(localized: \"content.accessibility.back_to_main_chat\", comment: \"Accessibility label for returning to main chat\")\n                    )\n\n                    Spacer(minLength: 0)\n\n                    HStack(spacing: 8) {\n                        privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)\n                        let isFavorite = viewModel.isFavorite(peerID: headerContext.headerPeerID)\n\n                        if !privatePeerID.isGeoDM {\n                            Button(action: {\n                                viewModel.toggleFavorite(peerID: headerContext.headerPeerID)\n                            }) {\n                                Image(systemName: isFavorite ? \"star.fill\" : \"star\")\n                                    .font(.bitchatSystem(size: 14))\n                                    .foregroundColor(isFavorite ? Color.yellow : textColor)\n                            }\n                            .buttonStyle(.plain)\n                            .accessibilityLabel(\n                                isFavorite\n                                ? String(localized: \"content.accessibility.remove_favorite\", comment: \"Accessibility label to remove a favorite\")\n                                : String(localized: \"content.accessibility.add_favorite\", comment: \"Accessibility label to add a favorite\")\n                            )\n                        }\n                    }\n                    .frame(maxWidth: .infinity)\n\n                    Spacer(minLength: 0)\n\n                    Button(action: {\n                        withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {\n                            viewModel.endPrivateChat()\n                            showSidebar = true\n                        }\n                    }) {\n                        Image(systemName: \"xmark\")\n                            .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))\n                            .frame(width: 32, height: 32)\n                    }\n                \n                    .buttonStyle(.plain)\n                    .accessibilityLabel(\"Close\")\n                }\n                .frame(height: headerHeight)\n                .padding(.horizontal, 16)\n                .padding(.top, 10)\n                .padding(.bottom, 12)\n                .background(backgroundColor)\n            }\n\n            messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)\n                .background(backgroundColor)\n                .frame(maxWidth: .infinity, maxHeight: .infinity)\n            Divider()\n            inputView\n        }\n        .background(backgroundColor)\n        .foregroundColor(textColor)\n        .highPriorityGesture(\n            DragGesture(minimumDistance: 25, coordinateSpace: .local)\n                .onEnded { value in\n                    let horizontal = value.translation.width\n                    let vertical = abs(value.translation.height)\n                    guard horizontal > 80, vertical < 60 else { return }\n                    withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {\n                        showSidebar = true\n                        viewModel.endPrivateChat()\n                    }\n                }\n        )\n    }\n\n    private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: PeerID) -> some View {\n        Button(action: {\n            viewModel.showFingerprint(for: context.headerPeerID)\n        }) {\n            HStack(spacing: 6) {\n                if let connectionState = context.peer?.connectionState {\n                    switch connectionState {\n                    case .bluetoothConnected:\n                        Image(systemName: \"dot.radiowaves.left.and.right\")\n                            .font(.bitchatSystem(size: 14))\n                            .foregroundColor(textColor)\n                            .accessibilityLabel(String(localized: \"content.accessibility.connected_mesh\", comment: \"Accessibility label for mesh-connected peer indicator\"))\n                    case .meshReachable:\n                        Image(systemName: \"point.3.filled.connected.trianglepath.dotted\")\n                            .font(.bitchatSystem(size: 14))\n                            .foregroundColor(textColor)\n                            .accessibilityLabel(String(localized: \"content.accessibility.reachable_mesh\", comment: \"Accessibility label for mesh-reachable peer indicator\"))\n                    case .nostrAvailable:\n                        Image(systemName: \"globe\")\n                            .font(.bitchatSystem(size: 14))\n                            .foregroundColor(.purple)\n                            .accessibilityLabel(String(localized: \"content.accessibility.available_nostr\", comment: \"Accessibility label for Nostr-available peer indicator\"))\n                    case .offline:\n                        EmptyView()\n                    }\n                } else if viewModel.meshService.isPeerReachable(context.headerPeerID) {\n                    Image(systemName: \"point.3.filled.connected.trianglepath.dotted\")\n                        .font(.bitchatSystem(size: 14))\n                        .foregroundColor(textColor)\n                        .accessibilityLabel(String(localized: \"content.accessibility.reachable_mesh\", comment: \"Accessibility label for mesh-reachable peer indicator\"))\n                } else if context.isNostrAvailable {\n                    Image(systemName: \"globe\")\n                        .font(.bitchatSystem(size: 14))\n                        .foregroundColor(.purple)\n                        .accessibilityLabel(String(localized: \"content.accessibility.available_nostr\", comment: \"Accessibility label for Nostr-available peer indicator\"))\n                } else if viewModel.meshService.isPeerConnected(context.headerPeerID) || viewModel.connectedPeers.contains(context.headerPeerID) {\n                    Image(systemName: \"dot.radiowaves.left.and.right\")\n                        .font(.bitchatSystem(size: 14))\n                        .foregroundColor(textColor)\n                        .accessibilityLabel(String(localized: \"content.accessibility.connected_mesh\", comment: \"Accessibility label for mesh-connected peer indicator\"))\n                }\n\n                Text(context.displayName)\n                    .font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))\n                    .foregroundColor(textColor)\n\n                if !privatePeerID.isGeoDM {\n                    let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)\n                    let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)\n                    if let icon = encryptionStatus.icon {\n                        Image(systemName: icon)\n                            .font(.bitchatSystem(size: 14))\n                            .foregroundColor(encryptionStatus == .noiseVerified ? textColor :\n                                             encryptionStatus == .noiseSecured ? textColor :\n                                             Color.red)\n                            .accessibilityLabel(\n                                String(\n                                    format: String(localized: \"content.accessibility.encryption_status\", comment: \"Accessibility label announcing encryption status\"),\n                                    locale: .current,\n                                    encryptionStatus.accessibilityDescription\n                                )\n                            )\n                    }\n                }\n            }\n        }\n        .buttonStyle(.plain)\n        .accessibilityLabel(\n            String(\n                format: String(localized: \"content.accessibility.private_chat_header\", comment: \"Accessibility label describing the private chat header\"),\n                locale: .current,\n                context.displayName\n            )\n        )\n        .accessibilityHint(\n            String(localized: \"content.accessibility.view_fingerprint_hint\", comment: \"Accessibility hint for viewing encryption fingerprint\")\n        )\n        .frame(height: headerHeight)\n    }\n\n    private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext {\n        let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)\n        let peer = viewModel.getPeer(byID: headerPeerID)\n\n        let displayName: String = {\n            if privatePeerID.isGeoDM, case .location(let ch) = locationManager.selectedChannel {\n                let disp = viewModel.geohashDisplayName(for: privatePeerID)\n                return \"#\\(ch.geohash)/@\\(disp)\"\n            }\n            if let name = peer?.displayName { return name }\n            if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }\n            if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID.id) ?? Data()),\n               !fav.peerNickname.isEmpty { return fav.peerNickname }\n            if headerPeerID.id.count == 16 {\n                let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)\n                if let id = candidates.first,\n                   let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {\n                    if let pet = social.localPetname, !pet.isEmpty { return pet }\n                    if !social.claimedNickname.isEmpty { return social.claimedNickname }\n                }\n            } else if let keyData = headerPeerID.noiseKey {\n                let fp = keyData.sha256Fingerprint()\n                if let social = viewModel.identityManager.getSocialIdentity(for: fp) {\n                    if let pet = social.localPetname, !pet.isEmpty { return pet }\n                    if !social.claimedNickname.isEmpty { return social.claimedNickname }\n                }\n            }\n            return String(localized: \"common.unknown\", comment: \"Fallback label for unknown peer\")\n        }()\n\n        let isNostrAvailable: Bool = {\n            guard let connectionState = peer?.connectionState else {\n                if let noiseKey = Data(hexString: headerPeerID.id),\n                   let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),\n                   favoriteStatus.isMutual {\n                    return true\n                }\n                return false\n            }\n            return connectionState == .nostrAvailable\n        }()\n\n        return PrivateHeaderContext(\n            headerPeerID: headerPeerID,\n            peer: peer,\n            displayName: displayName,\n            isNostrAvailable: isNostrAvailable\n        )\n    }\n\n    // Compute channel-aware people count and color for toolbar (cross-platform)\n    private func channelPeopleCountAndColor() -> (Int, Color) {\n        switch locationManager.selectedChannel {\n        case .location:\n            let n = viewModel.geohashPeople.count\n            let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)\n            return (n, n > 0 ? standardGreen : Color.secondary)\n        case .mesh:\n            let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in\n                guard peer.peerID != viewModel.meshService.myPeerID else { return }\n                if peer.isConnected { counts.mesh += 1; counts.others += 1 }\n                else if peer.isReachable { counts.others += 1 }\n            }\n            let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)\n            let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary\n            return (counts.others, color)\n        }\n    }\n\n    \n    private var mainHeaderView: some View {\n        HStack(spacing: 0) {\n            Text(verbatim: \"bitchat/\")\n                .font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))\n                .foregroundColor(textColor)\n                .onTapGesture(count: 3) {\n                    // PANIC: Triple-tap to clear all data\n                    viewModel.panicClearAllData()\n                }\n                .onTapGesture(count: 1) {\n                    // Single tap for app info\n                    showAppInfo = true\n                }\n            \n            HStack(spacing: 0) {\n                Text(verbatim: \"@\")\n                    .font(.bitchatSystem(size: 14, design: .monospaced))\n                    .foregroundColor(secondaryTextColor)\n                \n                TextField(\"content.input.nickname_placeholder\", text: $viewModel.nickname)\n                    .textFieldStyle(.plain)\n                    .font(.bitchatSystem(size: 14, design: .monospaced))\n                    .frame(maxWidth: 80)\n                    .foregroundColor(textColor)\n                    .focused($isNicknameFieldFocused)\n                    .autocorrectionDisabled(true)\n                    #if os(iOS)\n                    .textInputAutocapitalization(.never)\n                    #endif\n                    .modifier(FocusEffectDisabledModifier())\n                    .onChange(of: isNicknameFieldFocused) { isFocused in\n                        if !isFocused {\n                            // Only validate when losing focus\n                            viewModel.validateAndSaveNickname()\n                        }\n                    }\n                    .onSubmit {\n                        viewModel.validateAndSaveNickname()\n                    }\n            }\n            \n            Spacer()\n            \n            // Channel badge + dynamic spacing + people counter\n            // Precompute header count and color outside the ViewBuilder expressions\n            let cc = channelPeopleCountAndColor()\n            let headerCountColor: Color = cc.1\n            let headerOtherPeersCount: Int = {\n                if case .location = locationManager.selectedChannel {\n                    return viewModel.visibleGeohashPeople().count\n                }\n                return cc.0\n            }()\n\n            HStack(spacing: 10) {\n                // Unread icon immediately to the left of the channel badge (independent from channel button)\n                \n                // Unread indicator (now shown on iOS and macOS)\n                if viewModel.hasAnyUnreadMessages {\n                    Button(action: { viewModel.openMostRelevantPrivateChat() }) {\n                        Image(systemName: \"envelope.fill\")\n                            .font(.bitchatSystem(size: 12))\n                            .foregroundColor(Color.orange)\n                    }\n                    .buttonStyle(.plain)\n                    .accessibilityLabel(\n                        String(localized: \"content.accessibility.open_unread_private_chat\", comment: \"Accessibility label for the unread private chat button\")\n                    )\n                }\n                // Notes icon (mesh only and when location is authorized), to the left of #mesh\n                if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized {\n                    Button(action: {\n                        // Kick a one-shot refresh and show the sheet immediately.\n                        LocationChannelManager.shared.enableLocationChannels()\n                        LocationChannelManager.shared.refreshChannels()\n                        // If we already have a block geohash, pass it; otherwise wait in the sheet.\n                        notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash\n                        showLocationNotes = true\n                    }) {\n                        HStack(alignment: .center, spacing: 4) {\n                            Image(systemName: \"note.text\")\n                                .font(.bitchatSystem(size: 12))\n                                .foregroundColor(Color.orange.opacity(0.8))\n                                .padding(.top, 1)\n                        }\n                        .fixedSize(horizontal: true, vertical: false)\n                    }\n                    .buttonStyle(.plain)\n                    .accessibilityLabel(\n                        String(localized: \"content.accessibility.location_notes\", comment: \"Accessibility label for location notes button\")\n                    )\n                }\n\n                // Bookmark toggle (geochats): to the left of #geohash\n                if case .location(let ch) = locationManager.selectedChannel {\n                    Button(action: { bookmarks.toggle(ch.geohash) }) {\n                        Image(systemName: bookmarks.isBookmarked(ch.geohash) ? \"bookmark.fill\" : \"bookmark\")\n                            .font(.bitchatSystem(size: 12))\n                    }\n                    .buttonStyle(.plain)\n                    .accessibilityLabel(\n                        String(\n                            format: String(localized: \"content.accessibility.toggle_bookmark\", comment: \"Accessibility label for toggling a geohash bookmark\"),\n                            locale: .current,\n                            ch.geohash\n                        )\n                    )\n                }\n\n                // Location channels button '#'\n                Button(action: { showLocationChannelsSheet = true }) {\n                    let badgeText: String = {\n                        switch locationManager.selectedChannel {\n                        case .mesh: return \"#mesh\"\n                        case .location(let ch): return \"#\\(ch.geohash)\"\n                        }\n                    }()\n                    let badgeColor: Color = {\n                        switch locationManager.selectedChannel {\n                        case .mesh:\n                            return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)\n                        case .location:\n                            return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)\n                        }\n                    }()\n                    Text(badgeText)\n                        .font(.bitchatSystem(size: 14, design: .monospaced))\n                        .foregroundColor(badgeColor)\n                        .lineLimit(headerLineLimit)\n                        .fixedSize(horizontal: true, vertical: false)\n                        .layoutPriority(2)\n                        .accessibilityLabel(\n                            String(localized: \"content.accessibility.location_channels\", comment: \"Accessibility label for the location channels button\")\n                        )\n                }\n                .buttonStyle(.plain)\n                .padding(.leading, 4)\n                .padding(.trailing, 2)\n\n                HStack(spacing: 4) {\n                    // People icon with count\n                    Image(systemName: \"person.2.fill\")\n                        .font(.system(size: headerPeerIconSize, weight: .regular))\n                        .accessibilityLabel(\n                            String(\n                                format: String(localized: \"content.accessibility.people_count\", comment: \"Accessibility label announcing number of people in header\"),\n                                locale: .current,\n                                headerOtherPeersCount\n                            )\n                        )\n                    Text(\"\\(headerOtherPeersCount)\")\n                        .font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced))\n                        .accessibilityHidden(true)\n                }\n                .foregroundColor(headerCountColor)\n                .padding(.leading, 2)\n                .lineLimit(headerLineLimit)\n                .fixedSize(horizontal: true, vertical: false)\n\n                // QR moved to the PEOPLE header in the sidebar when on mesh channel\n            }\n            .layoutPriority(3)\n            .onTapGesture {\n                withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {\n                    showSidebar.toggle()\n                }\n            }\n            .sheet(isPresented: $showVerifySheet) {\n                VerificationSheetView(isPresented: $showVerifySheet)\n                    .environmentObject(viewModel)\n            }\n        }\n        .frame(height: headerHeight)\n        .padding(.horizontal, 12)\n        .sheet(isPresented: $showLocationChannelsSheet) {\n            LocationChannelsSheet(isPresented: $showLocationChannelsSheet)\n                .environmentObject(viewModel)\n                .onAppear { viewModel.isLocationChannelsSheetPresented = true }\n                .onDisappear { viewModel.isLocationChannelsSheetPresented = false }\n        }\n        .sheet(isPresented: $showLocationNotes, onDismiss: {\n            notesGeohash = nil\n        }) {\n            Group {\n                if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {\n                    LocationNotesView(geohash: gh)\n                        .environmentObject(viewModel)\n                } else {\n                    VStack(spacing: 12) {\n                        HStack {\n                            Text(\"content.notes.title\")\n                                .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))\n                            Spacer()\n                            Button(action: { showLocationNotes = false }) {\n                                Image(systemName: \"xmark\")\n                                    .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))\n                                    .foregroundColor(textColor)\n                                    .frame(width: 32, height: 32)\n                            }\n                            .buttonStyle(.plain)\n                            .accessibilityLabel(String(localized: \"common.close\", comment: \"Accessibility label for close buttons\"))\n                        }\n                        .frame(height: headerHeight)\n                        .padding(.horizontal, 12)\n                        .background(backgroundColor.opacity(0.95))\n                        Text(\"content.notes.location_unavailable\")\n                            .font(.bitchatSystem(size: 14, design: .monospaced))\n                            .foregroundColor(secondaryTextColor)\n                        Button(\"content.location.enable\") {\n                            LocationChannelManager.shared.enableLocationChannels()\n                            LocationChannelManager.shared.refreshChannels()\n                        }\n                        .buttonStyle(.bordered)\n                        Spacer()\n                    }\n                    .background(backgroundColor)\n                    .foregroundColor(textColor)\n                    // per-sheet global onChange added below\n                }\n            }\n            .onAppear {\n                // Ensure we are authorized and start live location updates (distance-filtered)\n                LocationChannelManager.shared.enableLocationChannels()\n                LocationChannelManager.shared.beginLiveRefresh()\n            }\n            .onDisappear {\n                LocationChannelManager.shared.endLiveRefresh()\n            }\n            .onChange(of: locationManager.availableChannels) { channels in\n                if let current = channels.first(where: { $0.level == .building })?.geohash,\n                    notesGeohash != current {\n                    notesGeohash = current\n                    #if os(iOS)\n                    // Light taptic when geohash changes while the sheet is open\n                    let generator = UIImpactFeedbackGenerator(style: .light)\n                    generator.prepare()\n                    generator.impactOccurred()\n                    #endif\n                }\n            }\n        }\n        .onAppear {\n            if case .mesh = locationManager.selectedChannel,\n               locationManager.permissionState == .authorized,\n               LocationChannelManager.shared.availableChannels.isEmpty {\n                LocationChannelManager.shared.refreshChannels()\n            }\n        }\n        .onChange(of: locationManager.selectedChannel) { _ in\n            if case .mesh = locationManager.selectedChannel,\n               locationManager.permissionState == .authorized,\n               LocationChannelManager.shared.availableChannels.isEmpty {\n                LocationChannelManager.shared.refreshChannels()\n            }\n        }\n        .onChange(of: locationManager.permissionState) { _ in\n            if case .mesh = locationManager.selectedChannel,\n               locationManager.permissionState == .authorized,\n               LocationChannelManager.shared.availableChannels.isEmpty {\n                LocationChannelManager.shared.refreshChannels()\n            }\n        }\n        .alert(\"content.alert.screenshot.title\", isPresented: $viewModel.showScreenshotPrivacyWarning) {\n            Button(\"common.ok\", role: .cancel) {}\n        } message: {\n            Text(\"content.alert.screenshot.message\")\n        }\n        .background(backgroundColor.opacity(0.95))\n    }\n\n}\n\n// MARK: - Helper Views\n\n// Rounded payment chip button\n//\n\nprivate enum MessageMedia {\n    case voice(URL)\n    case image(URL)\n\n    var url: URL {\n        switch self {\n        case .voice(let url), .image(let url):\n            return url\n        }\n    }\n}\n\nprivate extension ContentView {\n    func mediaAttachment(for message: BitchatMessage) -> MessageMedia? {\n        guard let baseDirectory = applicationFilesDirectory() else { return nil }\n\n        // Extract filename from message content\n        func url(from prefix: String, subdirectory: String) -> URL? {\n            guard message.content.hasPrefix(prefix) else { return nil }\n            let filename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !filename.isEmpty else { return nil }\n\n            // Construct URL directly without fileExists check (avoids blocking disk I/O in view body)\n            // Files are checked during playback/display, so missing files fail gracefully\n            let directory = baseDirectory.appendingPathComponent(subdirectory, isDirectory: true)\n            return directory.appendingPathComponent(filename)\n        }\n\n        // Try outgoing first (most common for sent media), fall back to incoming\n        if message.content.hasPrefix(\"[voice] \") {\n            let filename = String(message.content.dropFirst(\"[voice] \".count)).trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !filename.isEmpty else { return nil }\n            // Check outgoing first for sent messages, incoming for received\n            let subdir = message.sender == viewModel.nickname ? \"voicenotes/outgoing\" : \"voicenotes/incoming\"\n            let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)\n            return .voice(url)\n        }\n        if message.content.hasPrefix(\"[image] \") {\n            let filename = String(message.content.dropFirst(\"[image] \".count)).trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !filename.isEmpty else { return nil }\n            let subdir = message.sender == viewModel.nickname ? \"images/outgoing\" : \"images/incoming\"\n            let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)\n            return .image(url)\n        }\n        return nil\n    }\n\n    func mediaSendState(for message: BitchatMessage, mediaURL: URL) -> (isSending: Bool, progress: Double?, canCancel: Bool) {\n        var isSending = false\n        var progress: Double?\n        if let status = message.deliveryStatus {\n            switch status {\n            case .sending:\n                isSending = true\n                progress = 0\n            case .partiallyDelivered(let reached, let total):\n                if total > 0 {\n                    isSending = true\n                    progress = Double(reached) / Double(total)\n                }\n            case .sent, .read, .delivered, .failed:\n                break\n            }\n        }\n        let isOutgoing = mediaURL.path.contains(\"/outgoing/\")\n        let canCancel = isSending && isOutgoing\n        let clamped = progress.map { max(0, min(1, $0)) }\n        return (isSending, isSending ? clamped : nil, canCancel)\n    }\n\n    @ViewBuilder\n    private func messageRow(for message: BitchatMessage) -> some View {\n        if message.sender == \"system\" {\n            systemMessageRow(message)\n        } else if let media = mediaAttachment(for: message) {\n            mediaMessageRow(message: message, media: media)\n        } else {\n            TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs)\n        }\n    }\n\n    @ViewBuilder\n    private func systemMessageRow(_ message: BitchatMessage) -> some View {\n        Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))\n            .fixedSize(horizontal: false, vertical: true)\n            .frame(maxWidth: .infinity, alignment: .leading)\n    }\n\n    @ViewBuilder\n    private func mediaMessageRow(message: BitchatMessage, media: MessageMedia) -> some View {\n        let mediaURL = media.url\n        let state = mediaSendState(for: message, mediaURL: mediaURL)\n        let isOutgoing = mediaURL.path.contains(\"/outgoing/\")\n        let isAuthoredByUs = isOutgoing || (message.senderPeerID == viewModel.meshService.myPeerID)\n        let shouldBlurImage = !isAuthoredByUs\n        let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil\n\n        VStack(alignment: .leading, spacing: 2) {\n            HStack(alignment: .center, spacing: 4) {\n                Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme))\n                    .fixedSize(horizontal: false, vertical: true)\n                    .frame(maxWidth: .infinity, alignment: .leading)\n                if message.isPrivate && message.sender == viewModel.nickname,\n                   let status = message.deliveryStatus {\n                    DeliveryStatusView(status: status)\n                        .padding(.leading, 4)\n                }\n            }\n\n            Group {\n                switch media {\n                case .voice(let url):\n                    VoiceNoteView(\n                        url: url,\n                        isSending: state.isSending,\n                        sendProgress: state.progress,\n                        onCancel: cancelAction\n                    )\n                case .image(let url):\n                    BlockRevealImageView(\n                        url: url,\n                        revealProgress: state.progress,\n                        isSending: state.isSending,\n                        onCancel: cancelAction,\n                        initiallyBlurred: shouldBlurImage,\n                        onOpen: {\n                            if !state.isSending {\n                                imagePreviewURL = url\n                            }\n                        },\n                        onDelete: shouldBlurImage ? {\n                            viewModel.deleteMediaMessage(messageID: message.id)\n                        } : nil\n                    )\n                    .frame(maxWidth: 280)\n                }\n            }\n        }\n        .padding(.vertical, 4)\n    }\n\n    private func expandWindow(ifNeededFor message: BitchatMessage,\n                              allMessages: [BitchatMessage],\n                              privatePeer: PeerID?,\n                              proxy: ScrollViewProxy) {\n        let step = TransportConfig.uiWindowStepCount\n        let contextKey: String = {\n            if let peer = privatePeer { return \"dm:\\(peer)\" }\n            switch locationManager.selectedChannel {\n            case .mesh: return \"mesh\"\n            case .location(let ch): return \"geo:\\(ch.geohash)\"\n            }\n        }()\n        let preserveID = \"\\(contextKey)|\\(message.id)\"\n\n        if let peer = privatePeer {\n            let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate\n            let newCount = min(allMessages.count, current + step)\n            guard newCount != current else { return }\n            windowCountPrivate[peer] = newCount\n            DispatchQueue.main.async {\n                proxy.scrollTo(preserveID, anchor: .top)\n            }\n        } else {\n            let current = windowCountPublic\n            let newCount = min(allMessages.count, current + step)\n            guard newCount != current else { return }\n            windowCountPublic = newCount\n            DispatchQueue.main.async {\n                proxy.scrollTo(preserveID, anchor: .top)\n            }\n        }\n    }\n\n    var recordingIndicator: some View {\n        HStack(spacing: 12) {\n            Image(systemName: \"waveform.circle.fill\")\n                .foregroundColor(.red)\n                .font(.bitchatSystem(size: 20))\n            Text(\"recording \\(formattedRecordingDuration())\", comment: \"Voice note recording duration indicator\")\n                .font(.bitchatSystem(size: 13, design: .monospaced))\n                .foregroundColor(.red)\n            Spacer()\n            Button(action: cancelVoiceRecording) {\n                Label(\"Cancel\", systemImage: \"xmark.circle\")\n                    .labelStyle(.iconOnly)\n                    .font(.bitchatSystem(size: 18))\n                    .foregroundColor(.red)\n            }\n            .buttonStyle(.plain)\n        }\n        .padding(10)\n        .background(\n            RoundedRectangle(cornerRadius: 12)\n                .fill(Color.red.opacity(0.15))\n        )\n    }\n\n    private var trimmedMessageText: String {\n        messageText.trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n\n    private var shouldShowMediaControls: Bool {\n        if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {\n            return true\n        }\n        switch locationManager.selectedChannel {\n        case .mesh:\n            return true\n        case .location:\n            return false\n        }\n    }\n\n    private var shouldShowVoiceControl: Bool {\n        if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {\n            return true\n        }\n        switch locationManager.selectedChannel {\n        case .mesh:\n            return true\n        case .location:\n            return false\n        }\n    }\n\n    private var composerAccentColor: Color {\n        viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor\n    }\n\n    var attachmentButton: some View {\n        #if os(iOS)\n        Image(systemName: \"camera.circle.fill\")\n            .font(.bitchatSystem(size: 24))\n            .foregroundColor(composerAccentColor)\n            .frame(width: 36, height: 36)\n            .contentShape(Circle())\n            .onTapGesture {\n                // Tap = Photo Library\n                imagePickerSourceType = .photoLibrary\n                showImagePicker = true\n            }\n            .onLongPressGesture(minimumDuration: 0.3) {\n                // Long press = Camera\n                imagePickerSourceType = .camera\n                showImagePicker = true\n            }\n            .accessibilityLabel(\"Tap for library, long press for camera\")\n        #else\n        Button(action: { showMacImagePicker = true }) {\n            Image(systemName: \"photo.circle.fill\")\n                .font(.bitchatSystem(size: 24))\n                .foregroundColor(composerAccentColor)\n                .frame(width: 36, height: 36)\n        }\n        .buttonStyle(.plain)\n        .accessibilityLabel(\"Choose photo\")\n        #endif\n    }\n\n    @ViewBuilder\n    var sendOrMicButton: some View {\n        let hasText = !trimmedMessageText.isEmpty\n        if shouldShowVoiceControl {\n            ZStack {\n                micButtonView\n                    .opacity(hasText ? 0 : 1)\n                    .allowsHitTesting(!hasText)\n                sendButtonView(enabled: hasText)\n                    .opacity(hasText ? 1 : 0)\n                    .allowsHitTesting(hasText)\n            }\n            .frame(width: 36, height: 36)\n        } else {\n            sendButtonView(enabled: hasText)\n                .frame(width: 36, height: 36)\n        }\n    }\n\n    private var micButtonView: some View {\n        let tint = (isRecordingVoiceNote || isPreparingVoiceNote) ? Color.red : composerAccentColor\n\n        return Image(systemName: \"mic.circle.fill\")\n            .font(.bitchatSystem(size: 24))\n            .foregroundColor(tint)\n            .frame(width: 36, height: 36)\n            .contentShape(Circle())\n            .overlay(\n                Color.clear\n                    .contentShape(Circle())\n                    .gesture(\n                        DragGesture(minimumDistance: 0)\n                            .onChanged { _ in startVoiceRecording() }\n                            .onEnded { _ in finishVoiceRecording(send: true) }\n                    )\n            )\n            .accessibilityLabel(\"Hold to record a voice note\")\n    }\n\n    private func sendButtonView(enabled: Bool) -> some View {\n        let activeColor = composerAccentColor\n        return Button(action: sendMessage) {\n            Image(systemName: \"arrow.up.circle.fill\")\n                .font(.bitchatSystem(size: 24))\n                .foregroundColor(enabled ? activeColor : Color.gray)\n                .frame(width: 36, height: 36)\n        }\n        .buttonStyle(.plain)\n        .disabled(!enabled)\n        .accessibilityLabel(\n            String(localized: \"content.accessibility.send_message\", comment: \"Accessibility label for the send message button\")\n        )\n        .accessibilityHint(\n            enabled\n            ? String(localized: \"content.accessibility.send_hint_ready\", comment: \"Hint prompting the user to send the message\")\n            : String(localized: \"content.accessibility.send_hint_empty\", comment: \"Hint prompting the user to enter a message\")\n        )\n    }\n\n    func formattedRecordingDuration() -> String {\n        let clamped = max(0, recordingDuration)\n        let totalMilliseconds = Int((clamped * 1000).rounded())\n        let minutes = totalMilliseconds / 60_000\n        let seconds = (totalMilliseconds % 60_000) / 1_000\n        let centiseconds = (totalMilliseconds % 1_000) / 10\n        return String(format: \"%02d:%02d.%02d\", minutes, seconds, centiseconds)\n    }\n\n    func startVoiceRecording() {\n        guard shouldShowVoiceControl else { return }\n        guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }\n        isPreparingVoiceNote = true\n        Task { @MainActor in\n            let granted = await VoiceRecorder.shared.requestPermission()\n            guard granted else {\n                isPreparingVoiceNote = false\n                recordingAlertMessage = \"Microphone access is required to record voice notes.\"\n                showRecordingAlert = true\n                return\n            }\n            do {\n                _ = try VoiceRecorder.shared.startRecording()\n                recordingDuration = 0\n                recordingStartDate = Date()\n                recordingTimer?.invalidate()\n                recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in\n                    if let start = recordingStartDate {\n                        recordingDuration = Date().timeIntervalSince(start)\n                    }\n                }\n                if let timer = recordingTimer {\n                    RunLoop.main.add(timer, forMode: .common)\n                }\n                isPreparingVoiceNote = false\n                isRecordingVoiceNote = true\n            } catch {\n                SecureLogger.error(\"Voice recording failed to start: \\(error)\", category: .session)\n                recordingAlertMessage = \"Could not start recording.\"\n                showRecordingAlert = true\n                VoiceRecorder.shared.cancelRecording()\n                isPreparingVoiceNote = false\n                isRecordingVoiceNote = false\n                recordingStartDate = nil\n            }\n        }\n    }\n\n    func finishVoiceRecording(send: Bool) {\n        if isPreparingVoiceNote {\n            isPreparingVoiceNote = false\n            VoiceRecorder.shared.cancelRecording()\n            return\n        }\n        guard isRecordingVoiceNote else { return }\n        isRecordingVoiceNote = false\n        recordingTimer?.invalidate()\n        recordingTimer = nil\n        if let start = recordingStartDate {\n            recordingDuration = Date().timeIntervalSince(start)\n        }\n        recordingStartDate = nil\n        if send {\n            let minimumDuration: TimeInterval = 1.0\n            VoiceRecorder.shared.stopRecording { url in\n                DispatchQueue.main.async {\n                    guard\n                        let url = url,\n                        let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),\n                        let fileSize = attributes[.size] as? NSNumber,\n                        fileSize.intValue > 0,\n                        recordingDuration >= minimumDuration\n                    else {\n                        if let url = url {\n                            try? FileManager.default.removeItem(at: url)\n                        }\n                        recordingAlertMessage = recordingDuration < minimumDuration\n                            ? \"Recording is too short.\"\n                            : \"Recording failed to save.\"\n                        showRecordingAlert = true\n                        return\n                    }\n                    viewModel.sendVoiceNote(at: url)\n                }\n            }\n        } else {\n            VoiceRecorder.shared.cancelRecording()\n        }\n    }\n\n    func cancelVoiceRecording() {\n        if isPreparingVoiceNote || isRecordingVoiceNote {\n            finishVoiceRecording(send: false)\n        }\n    }\n\n    func handleImportResult(_ result: Result<[URL], Error>, handler: @escaping (URL) async -> Void) {\n        switch result {\n        case .success(let urls):\n            guard let url = urls.first else { return }\n            let needsStop = url.startAccessingSecurityScopedResource()\n            Task {\n                defer {\n                    if needsStop {\n                        url.stopAccessingSecurityScopedResource()\n                    }\n                }\n                await handler(url)\n            }\n        case .failure(let error):\n            SecureLogger.error(\"Media import failed: \\(error)\", category: .session)\n        }\n    }\n\n\n    func applicationFilesDirectory() -> URL? {\n        // Cache the directory lookup to avoid repeated FileManager calls during view rendering\n        struct Cache {\n            static var cachedURL: URL?\n            static var didAttempt = false\n        }\n\n        if Cache.didAttempt {\n            return Cache.cachedURL\n        }\n\n        do {\n            let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)\n            let filesDir = base.appendingPathComponent(\"files\", isDirectory: true)\n            try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)\n            Cache.cachedURL = filesDir\n            Cache.didAttempt = true\n            return filesDir\n        } catch {\n            SecureLogger.error(\"Failed to resolve application files directory: \\(error)\", category: .session)\n            Cache.didAttempt = true\n            return nil\n        }\n    }\n}\n\n//\n\nstruct ImagePreviewView: View {\n    let url: URL\n\n    @Environment(\\.dismiss) private var dismiss\n    #if os(iOS)\n    @State private var showExporter = false\n    @State private var platformImage: UIImage?\n    #else\n    @State private var platformImage: NSImage?\n    #endif\n\n    var body: some View {\n        ZStack {\n            Color.black.ignoresSafeArea()\n            VStack {\n                Spacer()\n                if let image = platformImage {\n                    #if os(iOS)\n                    Image(uiImage: image)\n                        .resizable()\n                        .aspectRatio(contentMode: .fit)\n                        .padding()\n                    #else\n                    Image(nsImage: image)\n                        .resizable()\n                        .aspectRatio(contentMode: .fit)\n                        .padding()\n                    #endif\n                } else {\n                    ProgressView()\n                        .progressViewStyle(.circular)\n                        .tint(.white)\n                }\n                Spacer()\n                HStack {\n                    Button(action: { dismiss() }) {\n                        Text(\"close\", comment: \"Button to dismiss fullscreen media viewer\")\n                            .font(.bitchatSystem(size: 15, weight: .semibold))\n                            .foregroundColor(.white)\n                            .padding(.horizontal, 16)\n                            .padding(.vertical, 8)\n                            .background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1))\n                    }\n                    Spacer()\n                    Button(action: saveCopy) {\n                        Text(\"save\", comment: \"Button to save media to device\")\n                            .font(.bitchatSystem(size: 15, weight: .semibold))\n                            .foregroundColor(.white)\n                            .padding(.horizontal, 16)\n                            .padding(.vertical, 8)\n                            .background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6)))\n                    }\n                }\n                .padding([.horizontal, .bottom], 24)\n            }\n        }\n        .onAppear(perform: loadImage)\n        #if os(iOS)\n        .sheet(isPresented: $showExporter) {\n            FileExportWrapper(url: url)\n        }\n        #endif\n    }\n\n    private func loadImage() {\n        DispatchQueue.global(qos: .userInitiated).async {\n            #if os(iOS)\n            guard let image = UIImage(contentsOfFile: url.path) else { return }\n            #else\n            guard let image = NSImage(contentsOf: url) else { return }\n            #endif\n            DispatchQueue.main.async {\n                self.platformImage = image\n            }\n        }\n    }\n\n    private func saveCopy() {\n        #if os(iOS)\n        showExporter = true\n        #else\n        Task { @MainActor in\n            let panel = NSSavePanel()\n            panel.canCreateDirectories = true\n            panel.nameFieldStringValue = url.lastPathComponent\n            panel.prompt = \"save\"\n            if panel.runModal() == .OK, let destination = panel.url {\n                do {\n                    if FileManager.default.fileExists(atPath: destination.path) {\n                        try FileManager.default.removeItem(at: destination)\n                    }\n                    try FileManager.default.copyItem(at: url, to: destination)\n                } catch {\n                    SecureLogger.error(\"Failed to save image preview copy: \\(error)\", category: .session)\n                }\n            }\n        }\n        #endif\n    }\n\n    #if os(iOS)\n    private struct FileExportWrapper: UIViewControllerRepresentable {\n        let url: URL\n\n        func makeUIViewController(context: Context) -> UIDocumentPickerViewController {\n            let controller = UIDocumentPickerViewController(forExporting: [url])\n            controller.shouldShowFileExtensions = true\n            return controller\n        }\n\n        func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}\n    }\n#endif\n}\n\n#if os(iOS)\n// MARK: - Image Picker (Camera or Photo Library)\nstruct ImagePickerView: UIViewControllerRepresentable {\n    let sourceType: UIImagePickerController.SourceType\n    let completion: (UIImage?) -> Void\n\n    func makeUIViewController(context: Context) -> UIImagePickerController {\n        let picker = UIImagePickerController()\n        picker.sourceType = sourceType\n        picker.delegate = context.coordinator\n        picker.allowsEditing = false\n\n        // Use standard full screen - iOS handles safe areas automatically\n        picker.modalPresentationStyle = .fullScreen\n\n        // Force dark mode to make safe area bars black instead of white\n        picker.overrideUserInterfaceStyle = .dark\n\n        return picker\n    }\n\n    func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}\n\n    func makeCoordinator() -> Coordinator {\n        Coordinator(completion: completion)\n    }\n\n    class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {\n        let completion: (UIImage?) -> Void\n\n        init(completion: @escaping (UIImage?) -> Void) {\n            self.completion = completion\n        }\n\n        func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {\n            let image = info[.originalImage] as? UIImage\n            completion(image)\n        }\n\n        func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {\n            completion(nil)\n        }\n    }\n}\n#endif\n\n#if os(macOS)\n// MARK: - macOS Image Picker\nstruct MacImagePickerView: View {\n    let completion: (URL?) -> Void\n    @Environment(\\.dismiss) private var dismiss\n\n    var body: some View {\n        VStack(spacing: 16) {\n            Text(\"Choose an image\")\n                .font(.headline)\n\n            Button(\"Select Image\") {\n                let panel = NSOpenPanel()\n                panel.allowsMultipleSelection = false\n                panel.canChooseDirectories = false\n                panel.canChooseFiles = true\n                panel.allowedContentTypes = [.image, .png, .jpeg, .heic]\n                panel.message = \"Choose an image to send\"\n\n                if panel.runModal() == .OK {\n                    completion(panel.url)\n                } else {\n                    dismiss()\n                }\n            }\n            .buttonStyle(.borderedProminent)\n\n            Button(\"Cancel\") {\n                completion(nil)\n            }\n            .buttonStyle(.bordered)\n        }\n        .padding(40)\n        .frame(minWidth: 300, minHeight: 150)\n    }\n}\n#endif\n"
  },
  {
    "path": "bitchat/Views/FingerprintView.swift",
    "content": "//\n// FingerprintView.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport SwiftUI\n\nstruct FingerprintView: View {\n    @ObservedObject var viewModel: ChatViewModel\n    let peerID: PeerID\n    @Environment(\\.dismiss) var dismiss\n    @Environment(\\.colorScheme) var colorScheme\n    \n    private var textColor: Color {\n        colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)\n    }\n    \n    private var backgroundColor: Color {\n        colorScheme == .dark ? Color.black : Color.white\n    }\n\n    private enum Strings {\n        static let title: LocalizedStringKey = \"fingerprint.title\"\n        static let theirFingerprint: LocalizedStringKey = \"fingerprint.their_label\"\n        static let handshakePending: LocalizedStringKey = \"fingerprint.handshake_pending\"\n        static let yourFingerprint: LocalizedStringKey = \"fingerprint.your_label\"\n        static let copy: LocalizedStringKey = \"common.copy\"\n        static let verifiedBadge: LocalizedStringKey = \"fingerprint.badge.verified\"\n        static let notVerifiedBadge: LocalizedStringKey = \"fingerprint.badge.not_verified\"\n        static let verifiedMessage: LocalizedStringKey = \"fingerprint.message.verified\"\n        static func verifyHint(_ nickname: String) -> String {\n            String(\n                format: String(localized: \"fingerprint.message.verify_hint\", comment: \"Instruction to compare fingerprints with a named peer\"),\n                locale: .current,\n                nickname\n            )\n        }\n        static let markVerified: LocalizedStringKey = \"fingerprint.action.mark_verified\"\n        static let removeVerification: LocalizedStringKey = \"fingerprint.action.remove_verification\"\n        static func unknownPeer() -> String {\n            String(localized: \"common.unknown\", comment: \"Label for an unknown peer\")\n        }\n    }\n    \n    var body: some View {\n        VStack(spacing: 20) {\n            // Header\n            HStack {\n                Text(Strings.title)\n                    .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))\n                    .foregroundColor(textColor)\n                \n                Spacer()\n                \n                Button(action: { dismiss() }) {\n                    Image(systemName: \"xmark\")\n                        .font(.bitchatSystem(size: 14, weight: .semibold))\n                }\n                .foregroundColor(textColor)\n            }\n            .padding()\n            \n            VStack(alignment: .leading, spacing: 16) {\n                // Prefer short mesh ID for session/encryption status\n                let statusPeerID = viewModel.getShortIDForNoiseKey(peerID)\n                // Resolve a friendly name\n                let peerNickname: String = {\n                    if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }\n                    if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }\n                    if let data = peerID.noiseKey {\n                        if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }\n                        let fp = data.sha256Fingerprint()\n                        if let social = viewModel.identityManager.getSocialIdentity(for: fp) {\n                            if let pet = social.localPetname, !pet.isEmpty { return pet }\n                            if !social.claimedNickname.isEmpty { return social.claimedNickname }\n                        }\n                    }\n                    return Strings.unknownPeer()\n                }()\n                // Accurate encryption state based on short ID session\n                let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)\n                \n                HStack {\n                    if let icon = encryptionStatus.icon {\n                        Image(systemName: icon)\n                            .font(.bitchatSystem(size: 20))\n                            .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)\n                    }\n                    \n                    VStack(alignment: .leading, spacing: 4) {\n                        Text(peerNickname)\n                            .font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))\n                            .foregroundColor(textColor)\n                        \n                        Text(encryptionStatus.description)\n                            .font(.bitchatSystem(size: 12, design: .monospaced))\n                            .foregroundColor(textColor.opacity(0.7))\n                    }\n                    \n                    Spacer()\n                }\n                .padding()\n                .background(Color.gray.opacity(0.1))\n                .cornerRadius(8)\n                \n                // Their fingerprint\n                VStack(alignment: .leading, spacing: 8) {\n                    Text(Strings.theirFingerprint)\n                        .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))\n                        .foregroundColor(textColor.opacity(0.7))\n                    \n                    if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {\n                        Text(formatFingerprint(fingerprint))\n                            .font(.bitchatSystem(size: 14, design: .monospaced))\n                            .foregroundColor(textColor)\n                            .multilineTextAlignment(.leading)\n                            .lineLimit(nil)\n                            .fixedSize(horizontal: false, vertical: true)\n                            .padding()\n                            .frame(maxWidth: .infinity)\n                            .background(Color.gray.opacity(0.1))\n                            .cornerRadius(8)\n                            .contextMenu {\n                                Button(Strings.copy) {\n                                    #if os(iOS)\n                                    UIPasteboard.general.string = fingerprint\n                                    #else\n                                    NSPasteboard.general.clearContents()\n                                    NSPasteboard.general.setString(fingerprint, forType: .string)\n                                    #endif\n                                }\n                            }\n                    } else {\n                        Text(Strings.handshakePending)\n                            .font(.bitchatSystem(size: 14, design: .monospaced))\n                            .foregroundColor(Color.orange)\n                            .padding()\n                    }\n                }\n\n                // My fingerprint\n                VStack(alignment: .leading, spacing: 8) {\n                    Text(Strings.yourFingerprint)\n                        .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))\n                        .foregroundColor(textColor.opacity(0.7))\n                    \n                    let myFingerprint = viewModel.getMyFingerprint()\n                    Text(formatFingerprint(myFingerprint))\n                        .font(.bitchatSystem(size: 14, design: .monospaced))\n                        .foregroundColor(textColor)\n                        .multilineTextAlignment(.leading)\n                        .lineLimit(nil)\n                        .fixedSize(horizontal: false, vertical: true)\n                        .padding()\n                        .frame(maxWidth: .infinity)\n                        .background(Color.gray.opacity(0.1))\n                        .cornerRadius(8)\n                        .contextMenu {\n                            Button(Strings.copy) {\n                                #if os(iOS)\n                                UIPasteboard.general.string = myFingerprint\n                                #else\n                                NSPasteboard.general.clearContents()\n                                NSPasteboard.general.setString(myFingerprint, forType: .string)\n                                #endif\n                            }\n                        }\n                }\n                \n                // Verification status\n                if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {\n                    let isVerified = encryptionStatus == .noiseVerified\n                    \n                    VStack(spacing: 12) {\n                        Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)\n                            .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))\n                            .foregroundColor(isVerified ? Color.green : Color.orange)\n                            .frame(maxWidth: .infinity)\n                        \n                        Group {\n                            if isVerified {\n                                Text(Strings.verifiedMessage)\n                            } else {\n                                Text(Strings.verifyHint(peerNickname))\n                            }\n                        }\n                            .font(.bitchatSystem(size: 12, design: .monospaced))\n                            .foregroundColor(textColor.opacity(0.7))\n                            .multilineTextAlignment(.center)\n                            .lineLimit(nil)\n                            .fixedSize(horizontal: false, vertical: true)\n                            .frame(maxWidth: .infinity)\n                        \n                        if !isVerified {\n                            Button(action: {\n                                viewModel.verifyFingerprint(for: peerID)\n                                dismiss()\n                            }) {\n                                Text(Strings.markVerified)\n                                    .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))\n                                    .foregroundColor(.white)\n                                    .padding(.horizontal, 20)\n                                    .padding(.vertical, 10)\n                                    .background(Color.green)\n                                    .cornerRadius(8)\n                            }\n                            .buttonStyle(PlainButtonStyle())\n                        } else {\n                            Button(action: {\n                                viewModel.unverifyFingerprint(for: peerID)\n                                dismiss()\n                            }) {\n                                Text(Strings.removeVerification)\n                                    .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))\n                                    .foregroundColor(.white)\n                                    .padding(.horizontal, 20)\n                                    .padding(.vertical, 10)\n                                    .background(Color.red)\n                                    .cornerRadius(8)\n                            }\n                            .buttonStyle(PlainButtonStyle())\n                        }\n                    }\n                    .padding(.top)\n                    .frame(maxWidth: .infinity)\n                }\n            }\n            .padding()\n            .frame(maxWidth: 500) // Constrain max width for better readability\n            \n            Spacer()\n        }\n        .padding()\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n        .background(backgroundColor)\n    }\n    \n    private func formatFingerprint(_ fingerprint: String) -> String {\n        // Convert to uppercase and format into 4 lines (4 groups of 4 on each line)\n        let uppercased = fingerprint.uppercased()\n        var formatted = \"\"\n        \n        for (index, char) in uppercased.enumerated() {\n            // Add space every 4 characters (but not at the start)\n            if index > 0 && index % 4 == 0 {\n                // Add newline after every 16 characters (4 groups of 4)\n                if index % 16 == 0 {\n                    formatted += \"\\n\"\n                } else {\n                    formatted += \" \"\n                }\n            }\n            formatted += String(char)\n        }\n        \n        return formatted\n    }\n}\n"
  },
  {
    "path": "bitchat/Views/GeohashPeopleList.swift",
    "content": "import SwiftUI\n\nstruct GeohashPeopleList: View {\n    @ObservedObject var viewModel: ChatViewModel\n    let textColor: Color\n    let secondaryTextColor: Color\n    let onTapPerson: () -> Void\n    @Environment(\\.colorScheme) var colorScheme\n    @State private var orderedIDs: [String] = []\n\n    private enum Strings {\n        static let noneNearby: LocalizedStringKey = \"geohash_people.none_nearby\"\n        static let youSuffix: LocalizedStringKey = \"geohash_people.you_suffix\"\n        static let blockedTooltip = String(localized: \"geohash_people.tooltip.blocked\", comment: \"Tooltip shown next to users blocked in geohash channels\")\n        static let unblock: LocalizedStringKey = \"geohash_people.action.unblock\"\n        static let block: LocalizedStringKey = \"geohash_people.action.block\"\n    }\n\n    var body: some View {\n        if viewModel.visibleGeohashPeople().isEmpty {\n            VStack(alignment: .leading, spacing: 0) {\n                Text(Strings.noneNearby)\n                    .font(.bitchatSystem(size: 14, design: .monospaced))\n                    .foregroundColor(secondaryTextColor)\n                    .padding(.horizontal)\n                    .padding(.top, 12)\n            }\n        } else {\n            let myHex: String? = {\n                if case .location(let ch) = LocationChannelManager.shared.selectedChannel,\n                   let id = try? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) {\n                    return id.publicKeyHex.lowercased()\n                }\n                return nil\n            }()\n            let people = viewModel.visibleGeohashPeople()\n            let currentIDs = people.map { $0.id }\n\n            let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() })\n            let isTeleportedID: (String) -> Bool = { id in\n                if teleportedSet.contains(id.lowercased()) { return true }\n                if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true }\n                return false\n            }\n\n            let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }\n            let nonTele = displayIDs.filter { !isTeleportedID($0) }\n            let tele = displayIDs.filter { isTeleportedID($0) }\n            let finalOrder: [String] = nonTele + tele\n            let firstID = finalOrder.first\n            let personByID = Dictionary(uniqueKeysWithValues: people.map { ($0.id, $0) })\n\n            VStack(alignment: .leading, spacing: 0) {\n                ForEach(finalOrder.filter { personByID[$0] != nil }, id: \\.self) { pid in\n                    let person = personByID[pid]!\n                    HStack(spacing: 4) {\n                        let isMe = (person.id == myHex)\n                        let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported)\n                        let icon = teleported ? \"face.dashed\" : \"mappin.and.ellipse\"\n                        let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)\n                        let rowColor: Color = isMe ? .orange : assignedColor\n                        Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)\n\n                        let (base, suffix) = person.displayName.splitSuffix()\n                        HStack(spacing: 0) {\n                            Text(base)\n                                .font(.bitchatSystem(size: 14, design: .monospaced))\n                                .fontWeight(isMe ? .bold : .regular)\n                                .foregroundColor(rowColor)\n                            if !suffix.isEmpty {\n                                let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)\n                                Text(suffix)\n                                    .font(.bitchatSystem(size: 14, design: .monospaced))\n                                    .foregroundColor(suffixColor)\n                            }\n                            if isMe {\n                                Text(Strings.youSuffix)\n                                    .font(.bitchatSystem(size: 14, design: .monospaced))\n                                    .foregroundColor(rowColor)\n                            }\n                        }\n                        if let me = myHex, person.id != me {\n                            if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {\n                                Image(systemName: \"nosign\")\n                                    .font(.bitchatSystem(size: 10))\n                                    .foregroundColor(.red)\n                                    .help(Strings.blockedTooltip)\n                            }\n                        }\n                        Spacer()\n                    }\n                    .padding(.horizontal)\n                    .padding(.vertical, 4)\n                    .padding(.top, person.id == firstID ? 10 : 0)\n                    .contentShape(Rectangle())\n                    .onTapGesture {\n                        if person.id != myHex {\n                            viewModel.startGeohashDM(withPubkeyHex: person.id)\n                            onTapPerson()\n                        }\n                    }\n                    .contextMenu {\n                        if let me = myHex, person.id == me {\n                            EmptyView()\n                        } else {\n                            let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)\n                            if blocked {\n                                Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }\n                            } else {\n                                Button(Strings.block) { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }\n                            }\n                        }\n                    }\n                }\n            }\n            // Seed and update order outside result builder\n            .onAppear {\n                orderedIDs = currentIDs\n            }\n            .onChange(of: currentIDs) { ids in\n                var newOrder = orderedIDs\n                newOrder.removeAll { !ids.contains($0) }\n                for id in ids where !newOrder.contains(id) { newOrder.append(id) }\n                if newOrder != orderedIDs { orderedIDs = newOrder }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Views/LocationChannelsSheet.swift",
    "content": "import SwiftUI\nimport CoreLocation\n#if os(iOS)\nimport UIKit\n#else\nimport AppKit\n#endif\nstruct LocationChannelsSheet: View {\n    @Binding var isPresented: Bool\n    @ObservedObject private var manager = LocationChannelManager.shared\n    @ObservedObject private var bookmarks = GeohashBookmarksStore.shared\n    @ObservedObject private var network = NetworkActivationService.shared\n    @EnvironmentObject var viewModel: ChatViewModel\n    @Environment(\\.colorScheme) var colorScheme\n    @State private var customGeohash: String = \"\"\n    @State private var customError: String? = nil\n\n    private var backgroundColor: Color { colorScheme == .dark ? .black : .white }\n\n    private enum Strings {\n        static let title: LocalizedStringKey = \"location_channels.title\"\n        static let description: LocalizedStringKey = \"location_channels.description\"\n        static let requestPermissions: LocalizedStringKey = \"location_channels.action.request_permissions\"\n        static let permissionDenied: LocalizedStringKey = \"location_channels.permission_denied\"\n        static let openSettings: LocalizedStringKey = \"location_channels.action.open_settings\"\n        static let loadingNearby: LocalizedStringKey = \"location_channels.loading_nearby\"\n        static let teleport: LocalizedStringKey = \"location_channels.action.teleport\"\n        static let bookmarked: LocalizedStringKey = \"location_channels.bookmarked_section_title\"\n        static let removeAccess: LocalizedStringKey = \"location_channels.action.remove_access\"\n        static let torTitle: LocalizedStringKey = \"location_channels.tor.title\"\n        static let torSubtitle: LocalizedStringKey = \"location_channels.tor.subtitle\"\n        static let toggleOn: LocalizedStringKey = \"common.toggle.on\"\n        static let toggleOff: LocalizedStringKey = \"common.toggle.off\"\n\n        static let invalidGeohash = String(localized: \"location_channels.error.invalid_geohash\", comment: \"Error shown when a custom geohash is invalid\")\n\n        static func meshTitle(_ count: Int) -> String {\n            let label = String(localized: \"location_channels.mesh_label\", comment: \"Label for the mesh channel row\")\n            return rowTitle(label: label, count: count)\n        }\n\n        static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {\n            // High-precision uncertainty: if count is 0 for high-precision levels,\n            // show \"?\" because presence broadcasting is disabled for privacy.\n            let isHighPrecision = (level == .neighborhood || level == .block || level == .building)\n            if isHighPrecision && count == 0 {\n                return String(\n                    format: String(localized: \"location_channels.row_title_unknown\", defaultValue: \"%@ [? people]\"),\n                    locale: .current,\n                    level.displayName\n                )\n            }\n            return rowTitle(label: level.displayName, count: count)\n        }\n\n        static func bookmarkTitle(geohash: String, count: Int) -> String {\n            // Check precision for bookmarks too\n            let len = geohash.count\n            // Neighborhood=6, Block=7, Building=8+\n            let isHighPrecision = (len >= 6)\n            if isHighPrecision && count == 0 {\n                return String(\n                    format: String(localized: \"location_channels.row_title_unknown\", defaultValue: \"%@ [? people]\"),\n                    locale: .current,\n                    \"#\\(geohash)\"\n                )\n            }\n            return rowTitle(label: \"#\\(geohash)\", count: count)\n        }\n\n        static func subtitlePrefix(geohash: String, coverage: String) -> String {\n            String(\n                format: String(localized: \"location_channels.subtitle_prefix\", comment: \"Subtitle prefix showing geohash and coverage\"),\n                locale: .current,\n                geohash, coverage\n            )\n        }\n\n        static func subtitle(prefix: String, name: String?) -> String {\n            guard let name, !name.isEmpty else { return prefix }\n            return String(\n                format: String(localized: \"location_channels.subtitle_with_name\", comment: \"Subtitle combining prefix and resolved location name\"),\n                locale: .current,\n                prefix, name\n            )\n        }\n\n        private static func rowTitle(label: String, count: Int) -> String {\n            String(\n                format: String(localized: \"location_channels.row_title\", comment: \"List row title with participant count\"),\n                locale: .current,\n                label, count\n            )\n        }\n    }\n\n    var body: some View {\n        NavigationView {\n            VStack(alignment: .leading, spacing: 12) {\n                HStack(spacing: 12) {\n                    Text(Strings.title)\n                        .font(.bitchatSystem(size: 18, design: .monospaced))\n                    Spacer()\n                    closeButton\n                }\n                Text(Strings.description)\n                    .font(.bitchatSystem(size: 12, design: .monospaced))\n                    .foregroundColor(.secondary)\n\n                Group {\n                    switch manager.permissionState {\n                    case LocationChannelManager.PermissionState.notDetermined:\n                        Button(action: { manager.enableLocationChannels() }) {\n                            Text(Strings.requestPermissions)\n                                .font(.bitchatSystem(size: 12, design: .monospaced))\n                                .foregroundColor(standardGreen)\n                                .frame(maxWidth: .infinity)\n                                .padding(.vertical, 6)\n                                .background(standardGreen.opacity(0.12))\n                                .cornerRadius(6)\n                        }\n                        .buttonStyle(.plain)\n                    case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:\n                        VStack(alignment: .leading, spacing: 8) {\n                            Text(Strings.permissionDenied)\n                                .font(.bitchatSystem(size: 12, design: .monospaced))\n                                .foregroundColor(.secondary)\n                            Button(Strings.openSettings) { openSystemLocationSettings() }\n                            .buttonStyle(.plain)\n                        }\n                    case LocationChannelManager.PermissionState.authorized:\n                        EmptyView()\n                    }\n                }\n\n                channelList\n                Spacer()\n            }\n            .padding(.horizontal, 16)\n            .padding(.vertical, 12)\n            .background(backgroundColor)\n            #if os(iOS)\n            .navigationBarTitleDisplayMode(.inline)\n            .navigationBarHidden(true)\n            #else\n            .navigationTitle(\"\")\n            #endif\n        }\n        #if os(macOS)\n        .frame(minWidth: 420, minHeight: 520)\n        #endif\n        .background(backgroundColor)\n        .onAppear {\n            // Refresh channels when opening\n            if manager.permissionState == LocationChannelManager.PermissionState.authorized {\n                manager.refreshChannels()\n            }\n            // Begin periodic refresh while sheet is open\n            manager.beginLiveRefresh()\n            // Geohash sampling is now managed by ChatViewModel globally\n        }\n        .onDisappear {\n            manager.endLiveRefresh()\n        }\n        .onChange(of: manager.permissionState) { newValue in\n            if newValue == LocationChannelManager.PermissionState.authorized {\n                manager.refreshChannels()\n            }\n        }\n        .onChange(of: manager.availableChannels) { _ in }\n    }\n\n    private var closeButton: some View {\n        Button(action: { isPresented = false }) {\n            Image(systemName: \"xmark\")\n                .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))\n                .frame(width: 32, height: 32)\n        }\n        .buttonStyle(.plain)\n        .accessibilityLabel(\"Close\")\n    }\n\n    private var channelList: some View {\n        ScrollView {\n            LazyVStack(spacing: 0) {\n                channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: \"bluetooth\", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {\n                    manager.select(ChannelID.mesh)\n                    isPresented = false\n                }\n                .padding(.vertical, 6)\n\n                let nearby = manager.availableChannels.filter { $0.level != .building }\n                if !nearby.isEmpty {\n                    ForEach(nearby) { channel in\n                        sectionDivider\n                        let coverage = coverageString(forPrecision: channel.geohash.count)\n                        let nameBase = locationName(for: channel.level)\n                        let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }\n                        let participantCount = viewModel.geohashParticipantCount(for: channel.geohash)\n                        let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)\n                        let highlight = participantCount > 0\n                        channelRow(\n                            title: Strings.levelTitle(for: channel.level, count: participantCount),\n                            subtitlePrefix: subtitlePrefix,\n                            subtitleName: namePart,\n                            isSelected: isSelected(channel),\n                            titleBold: highlight,\n                            trailingAccessory: {\n                                Button(action: { bookmarks.toggle(channel.geohash) }) {\n                                    Image(systemName: bookmarks.isBookmarked(channel.geohash) ? \"bookmark.fill\" : \"bookmark\")\n                                        .font(.bitchatSystem(size: 14))\n                                }\n                                .buttonStyle(.plain)\n                                .padding(.leading, 8)\n                            }\n                        ) {\n                            manager.markTeleported(for: channel.geohash, false)\n                            manager.select(ChannelID.location(channel))\n                            isPresented = false\n                        }\n                        .padding(.vertical, 6)\n                    }\n                } else {\n                    sectionDivider\n                    HStack(spacing: 8) {\n                        ProgressView()\n                        Text(Strings.loadingNearby)\n                            .font(.bitchatSystem(size: 12, design: .monospaced))\n                    }\n                    .frame(maxWidth: .infinity, alignment: .leading)\n                    .padding(.vertical, 10)\n                }\n\n                sectionDivider\n                customTeleportSection\n                    .padding(.vertical, 8)\n\n                let bookmarkedList = bookmarks.bookmarks\n                if !bookmarkedList.isEmpty {\n                    sectionDivider\n                    bookmarkedSection(bookmarkedList)\n                        .padding(.vertical, 8)\n                }\n\n                if manager.permissionState == LocationChannelManager.PermissionState.authorized {\n                    sectionDivider\n                    torToggleSection\n                        .padding(.top, 12)\n                    Button(action: {\n                        openSystemLocationSettings()\n                    }) {\n                        Text(Strings.removeAccess)\n                            .font(.bitchatSystem(size: 12, design: .monospaced))\n                            .foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))\n                            .frame(maxWidth: .infinity)\n                            .padding(.vertical, 6)\n                            .background(Color.red.opacity(0.08))\n                            .cornerRadius(6)\n                    }\n                    .buttonStyle(.plain)\n                    .padding(.vertical, 8)\n                }\n            }\n            .frame(maxWidth: .infinity, alignment: .leading)\n            .padding(.vertical, 6)\n            .background(backgroundColor)\n        }\n        .background(backgroundColor)\n    }\n\n    private var sectionDivider: some View {\n        Rectangle()\n            .fill(dividerColor)\n            .frame(height: 1)\n    }\n\n    private var dividerColor: Color {\n        colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)\n    }\n\n    private var customTeleportSection: some View {\n        VStack(alignment: .leading, spacing: 6) {\n            HStack(spacing: 2) {\n                Text(verbatim: \"#\")\n                    .font(.bitchatSystem(size: 14, design: .monospaced))\n                    .foregroundColor(.secondary)\n                TextField(\"geohash\", text: $customGeohash)\n                    #if os(iOS)\n                    .textInputAutocapitalization(.never)\n                    .autocorrectionDisabled(true)\n                    .keyboardType(.asciiCapable)\n                    #endif\n                    .font(.bitchatSystem(size: 14, design: .monospaced))\n                    .onChange(of: customGeohash) { newValue in\n                        let allowed = Set(\"0123456789bcdefghjkmnpqrstuvwxyz\")\n                        let filtered = newValue\n                            .lowercased()\n                            .replacingOccurrences(of: \"#\", with: \"\")\n                            .filter { allowed.contains($0) }\n                        if filtered.count > 12 {\n                            customGeohash = String(filtered.prefix(12))\n                        } else if filtered != newValue {\n                            customGeohash = filtered\n                        }\n                    }\n                let normalized = customGeohash\n                    .trimmingCharacters(in: .whitespacesAndNewlines)\n                    .lowercased()\n                    .replacingOccurrences(of: \"#\", with: \"\")\n                let isValid = validateGeohash(normalized)\n                Button(action: {\n                    let gh = normalized\n                    guard isValid else { customError = Strings.invalidGeohash; return }\n                    let level = levelForLength(gh.count)\n                    let ch = GeohashChannel(level: level, geohash: gh)\n                    manager.markTeleported(for: ch.geohash, true)\n                    manager.select(ChannelID.location(ch))\n                    isPresented = false\n                }) {\n                    HStack(spacing: 6) {\n                        Text(Strings.teleport)\n                            .font(.bitchatSystem(size: 14, design: .monospaced))\n                        Image(systemName: \"face.dashed\")\n                            .font(.bitchatSystem(size: 14))\n                    }\n                }\n                .buttonStyle(.plain)\n                .font(.bitchatSystem(size: 14, design: .monospaced))\n                .padding(.vertical, 6)\n                .padding(.horizontal, 10)\n                .background(Color.secondary.opacity(0.12))\n                .cornerRadius(6)\n                .opacity(isValid ? 1.0 : 0.4)\n                .disabled(!isValid)\n            }\n            if let err = customError {\n                Text(err)\n                    .font(.bitchatSystem(size: 12, design: .monospaced))\n                    .foregroundColor(.red)\n            }\n        }\n    }\n\n    private func bookmarkedSection(_ entries: [String]) -> some View {\n        VStack(alignment: .leading, spacing: 8) {\n            Text(Strings.bookmarked)\n                .font(.bitchatSystem(size: 12, design: .monospaced))\n                .foregroundColor(.secondary)\n            LazyVStack(spacing: 0) {\n                ForEach(Array(entries.enumerated()), id: \\.offset) { index, gh in\n                    let level = levelForLength(gh.count)\n                    let channel = GeohashChannel(level: level, geohash: gh)\n                    let coverage = coverageString(forPrecision: gh.count)\n                    let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)\n                    let name = bookmarks.bookmarkNames[gh]\n                    let participantCount = viewModel.geohashParticipantCount(for: gh)\n                    channelRow(\n                        title: Strings.bookmarkTitle(geohash: gh, count: participantCount),\n                        subtitlePrefix: subtitle,\n                        subtitleName: name.map { formattedNamePrefix(for: level) + $0 },\n                        isSelected: isSelected(channel),\n                        trailingAccessory: {\n                            Button(action: { bookmarks.toggle(gh) }) {\n                                Image(systemName: bookmarks.isBookmarked(gh) ? \"bookmark.fill\" : \"bookmark\")\n                                    .font(.bitchatSystem(size: 14))\n                            }\n                            .buttonStyle(.plain)\n                            .padding(.leading, 8)\n                        }\n                    ) {\n                        let inRegional = manager.availableChannels.contains { $0.geohash == gh }\n                        if !inRegional && !manager.availableChannels.isEmpty {\n                            manager.markTeleported(for: gh, true)\n                        } else {\n                            manager.markTeleported(for: gh, false)\n                        }\n                        manager.select(ChannelID.location(channel))\n                        isPresented = false\n                    }\n                    .padding(.vertical, 6)\n                    .onAppear { bookmarks.resolveBookmarkNameIfNeeded(for: gh) }\n\n                    if index < entries.count - 1 {\n                        sectionDivider\n                    }\n                }\n            }\n        }\n    }\n\n\n    private func isSelected(_ channel: GeohashChannel) -> Bool {\n        if case .location(let ch) = manager.selectedChannel {\n            return ch == channel\n        }\n        return false\n    }\n\n    private var isMeshSelected: Bool {\n        if case .mesh = manager.selectedChannel { return true }\n        return false\n    }\n\n    @ViewBuilder\n    private func channelRow(\n        title: String,\n        subtitlePrefix: String,\n        subtitleName: String? = nil,\n        subtitleNameBold: Bool = false,\n        isSelected: Bool,\n        titleColor: Color? = nil,\n        titleBold: Bool = false,\n        @ViewBuilder trailingAccessory: () -> some View = { EmptyView() },\n        action: @escaping () -> Void\n    ) -> some View {\n        HStack(alignment: .center, spacing: 8) {\n            VStack(alignment: .leading) {\n                // Render title with smaller font for trailing count in parentheses\n                let parts = splitTitleAndCount(title)\n                HStack(spacing: 4) {\n                    Text(parts.base)\n                            .font(.bitchatSystem(size: 14, design: .monospaced))\n                            .fontWeight(titleBold ? .bold : .regular)\n                            .foregroundColor(titleColor ?? Color.primary)\n                        if let count = parts.countSuffix, !count.isEmpty {\n                            Text(count)\n                                .font(.bitchatSystem(size: 11, design: .monospaced))\n                                .foregroundColor(.secondary)\n                        }\n                    }\n                let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)\n                Text(subtitleFull)\n                    .font(.bitchatSystem(size: 12, design: .monospaced))\n                    .foregroundColor(.secondary)\n                    .lineLimit(1)\n                    .truncationMode(.tail)\n                }\n                Spacer()\n                if isSelected {\n                    Text(verbatim: \"✔︎\")\n                        .font(.bitchatSystem(size: 16, design: .monospaced))\n                        .foregroundColor(standardGreen)\n                }\n                trailingAccessory()\n            }\n            .frame(maxWidth: .infinity, alignment: .leading)\n        .contentShape(Rectangle())\n        .onTapGesture(perform: action)\n    }\n\n    // Split a title like \"#mesh [3 people]\" into base and suffix \"[3 people]\"\n    private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) {\n        guard let idx = s.lastIndex(of: \"[\") else { return (s, nil) }\n        let prefix = String(s[..<idx]).trimmingCharacters(in: .whitespaces)\n        let suffix = String(s[idx...])\n        return (prefix, suffix)\n    }\n\n    // MARK: - Helpers for counts\n    private func meshCount() -> Int {\n        // Count mesh-connected OR mesh-reachable peers (exclude self)\n        let myID = viewModel.meshService.myPeerID\n        return viewModel.allPeers.reduce(0) { acc, peer in\n            if peer.peerID != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }\n            return acc\n        }\n    }\n\n    private func validateGeohash(_ s: String) -> Bool {\n        let allowed = Set(\"0123456789bcdefghjkmnpqrstuvwxyz\")\n        guard !s.isEmpty, s.count <= 12 else { return false }\n        return s.allSatisfy { allowed.contains($0) }\n    }\n\n    private func levelForLength(_ len: Int) -> GeohashChannelLevel {\n        switch len {\n        case 0...2: return .region\n        case 3...4: return .province\n        case 5: return .city\n        case 6: return .neighborhood\n        case 7: return .block\n        case 8: return .building\n        default: return .block\n        }\n    }\n}\n\n// MARK: - TOR Toggle & Standardized Colors\nextension LocationChannelsSheet {\n    private var torToggleBinding: Binding<Bool> {\n        Binding(\n            get: { network.userTorEnabled },\n            set: { network.setUserTorEnabled($0) }\n        )\n    }\n\n    private var torToggleSection: some View {\n        VStack(alignment: .leading, spacing: 8) {\n            Toggle(isOn: torToggleBinding) {\n                VStack(alignment: .leading, spacing: 2) {\n                    Text(Strings.torTitle)\n                        .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))\n                        .foregroundColor(.primary)\n                    Text(Strings.torSubtitle)\n                        .font(.bitchatSystem(size: 11, design: .monospaced))\n                        .foregroundColor(.secondary)\n                }\n            }\n            .toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))\n        }\n        .padding(12)\n        .background(Color.secondary.opacity(0.12))\n        .cornerRadius(8)\n    }\n\n    private var standardGreen: Color {\n        (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)\n    }\n    private var standardBlue: Color {\n        Color(red: 0.0, green: 0.478, blue: 1.0)\n    }\n}\n\nprivate struct IRCToggleStyle: ToggleStyle {\n    let accent: Color\n    let onLabel: LocalizedStringKey\n    let offLabel: LocalizedStringKey\n\n    func makeBody(configuration: Configuration) -> some View {\n        Button(action: { configuration.isOn.toggle() }) {\n            HStack(spacing: 12) {\n                configuration.label\n                Spacer()\n                Text(configuration.isOn ? onLabel : offLabel)\n                    .textCase(.uppercase)\n                    .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))\n                    .foregroundColor(configuration.isOn ? accent : .secondary)\n                    .padding(.vertical, 4)\n                    .padding(.horizontal, 10)\n                    .background(\n                        RoundedRectangle(cornerRadius: 6)\n                            .fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))\n                    )\n                    .overlay(\n                        RoundedRectangle(cornerRadius: 6)\n                            .stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)\n                    )\n            }\n        }\n        .buttonStyle(.plain)\n    }\n}\n\n// MARK: - Coverage helpers\nextension LocationChannelsSheet {\n    private func coverageString(forPrecision len: Int) -> String {\n        // Approximate max cell dimension at equator for a given geohash length.\n        // Values sourced from common geohash dimension tables.\n        let maxMeters: Double = {\n            switch len {\n            case 2: return 1_250_000\n            case 3: return 156_000\n            case 4: return 39_100\n            case 5: return 4_890\n            case 6: return 1_220\n            case 7: return 153\n            case 8: return 38.2\n            case 9: return 4.77\n            case 10: return 1.19\n            default:\n                if len <= 1 { return 5_000_000 }\n                // For >10, scale down conservatively by ~1/4 each char\n                let over = len - 10\n                return 1.19 * pow(0.25, Double(over))\n            }\n        }()\n\n        let usesMetric: Bool = {\n            if #available(iOS 16.0, macOS 13.0, *) {\n                return Locale.current.measurementSystem == .metric\n            } else {\n                return Locale.current.usesMetricSystem\n            }\n        }()\n        if usesMetric {\n            let km = maxMeters / 1000.0\n            return \"~\\(formatDistance(km)) km\"\n        } else {\n            let miles = maxMeters / 1609.344\n            return \"~\\(formatDistance(miles)) mi\"\n        }\n    }\n\n    private func formatDistance(_ value: Double) -> String {\n        if value >= 100 { return String(format: \"%.0f\", value.rounded()) }\n        if value >= 10 { return String(format: \"%.1f\", value) }\n        return String(format: \"%.1f\", value)\n    }\n\n    private func bluetoothRangeString() -> String {\n        let usesMetric: Bool = {\n            if #available(iOS 16.0, macOS 13.0, *) {\n                return Locale.current.measurementSystem == .metric\n            } else {\n                return Locale.current.usesMetricSystem\n            }\n        }()\n        // Approximate Bluetooth LE range for typical mobile devices; environment dependent\n        return usesMetric ? \"~10–50 m\" : \"~30–160 ft\"\n    }\n\n    private func locationName(for level: GeohashChannelLevel) -> String? {\n        manager.locationNames[level]\n    }\n\n    private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {\n        switch level {\n        case .region:\n            return \"\"\n        case .building, .block, .neighborhood, .city, .province:\n            return \"~\"\n        }\n    }\n}\n\n// MARK: - Open Settings helper\nprivate func openSystemLocationSettings() {\n    #if os(iOS)\n    if let url = URL(string: UIApplication.openSettingsURLString) {\n        UIApplication.shared.open(url)\n    }\n    #else\n    if let url = URL(string: \"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices\") {\n        NSWorkspace.shared.open(url)\n    } else if let url = URL(string: \"x-apple.systempreferences:com.apple.preference.security\") {\n        NSWorkspace.shared.open(url)\n    }\n    #endif\n}\n"
  },
  {
    "path": "bitchat/Views/LocationNotesView.swift",
    "content": "import SwiftUI\n\nstruct LocationNotesView: View {\n    @EnvironmentObject var viewModel: ChatViewModel\n    @StateObject private var manager: LocationNotesManager\n    let geohash: String\n    let onNotesCountChanged: ((Int) -> Void)?\n\n    @Environment(\\.colorScheme) var colorScheme\n    @Environment(\\.dynamicTypeSize) private var dynamicTypeSize\n    @ObservedObject private var locationManager = LocationChannelManager.shared\n    @Environment(\\.dismiss) private var dismiss\n    @State private var draft: String = \"\"\n\n    init(\n        geohash: String,\n        onNotesCountChanged: ((Int) -> Void)? = nil,\n        manager: LocationNotesManager? = nil\n    ) {\n        let gh = geohash.lowercased()\n        self.geohash = gh\n        self.onNotesCountChanged = onNotesCountChanged\n        _manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))\n    }\n\n    private var backgroundColor: Color { colorScheme == .dark ? .black : .white }\n    private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }\n    private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }\n\n    private enum Strings {\n        static let closeAccessibility = String(localized: \"common.close\", comment: \"Accessibility label for close buttons\")\n        static let description: LocalizedStringKey = \"location_notes.description\"\n        static let loadingRecent: LocalizedStringKey = \"location_notes.loading_recent\"\n        static let relaysPaused: LocalizedStringKey = \"location_notes.relays_paused\"\n        static let noRelaysNearby: LocalizedStringKey = \"location_notes.no_relays_nearby\"\n        static let retry: LocalizedStringKey = \"location_notes.action.retry\"\n        static let relaysRetryHint: LocalizedStringKey = \"location_notes.relays_retry_hint\"\n        static let loadingNotes: LocalizedStringKey = \"location_notes.loading_notes\"\n        static let emptyTitle: LocalizedStringKey = \"location_notes.empty_title\"\n        static let emptySubtitle: LocalizedStringKey = \"location_notes.empty_subtitle\"\n        static let dismissError: LocalizedStringKey = \"location_notes.action.dismiss\"\n        static let addPlaceholder: LocalizedStringKey = \"location_notes.placeholder\"\n    }\n\n    var body: some View {\n#if os(macOS)\n        VStack(spacing: 0) {\n            ScrollView {\n                VStack(spacing: 0) {\n                    headerSection\n                    notesContent\n                }\n            }\n            .background(backgroundColor)\n            inputSection\n        }\n        .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)\n        .background(backgroundColor)\n        .onDisappear { manager.cancel() }\n        .onChange(of: geohash) { newValue in\n            manager.setGeohash(newValue)\n        }\n        .onAppear { onNotesCountChanged?(manager.notes.count) }\n        .onChange(of: manager.notes.count) { newValue in\n            onNotesCountChanged?(newValue)\n        }\n#else\n        NavigationView {\n            VStack(spacing: 0) {\n                headerSection\n                ScrollView {\n                    notesContent\n                }\n                .frame(maxWidth: .infinity, maxHeight: .infinity)\n                inputSection\n            }\n            .background(backgroundColor)\n            #if os(iOS)\n            .navigationBarTitleDisplayMode(.inline)\n            .navigationBarHidden(true)\n            #else\n            .navigationTitle(\"\")\n            #endif\n        }\n        .background(backgroundColor)\n        .onDisappear { manager.cancel() }\n        .onChange(of: geohash) { newValue in\n            manager.setGeohash(newValue)\n        }\n        .onAppear { onNotesCountChanged?(manager.notes.count) }\n        .onChange(of: manager.notes.count) { newValue in\n            onNotesCountChanged?(newValue)\n        }\n#endif\n    }\n\n    private var closeButton: some View {\n        Button(action: { dismiss() }) {\n            Image(systemName: \"xmark\")\n                .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))\n                .frame(width: 32, height: 32)\n        }\n        .buttonStyle(.plain)\n        .accessibilityLabel(Strings.closeAccessibility)\n    }\n\n    private var headerSection: some View {\n        let count = manager.notes.count\n        return VStack(alignment: .leading, spacing: 8) {\n            HStack(spacing: 12) {\n                Text(headerTitle(for: count))\n                    .font(.bitchatSystem(size: 18, design: .monospaced))\n                Spacer()\n                closeButton\n            }\n            if let building = locationManager.locationNames[.building], !building.isEmpty {\n                Text(building)\n                    .font(.bitchatSystem(size: 12, design: .monospaced))\n                    .foregroundColor(accentGreen)\n            } else if let block = locationManager.locationNames[.block], !block.isEmpty {\n                Text(block)\n                    .font(.bitchatSystem(size: 12, design: .monospaced))\n                    .foregroundColor(accentGreen)\n            }\n            Text(Strings.description)\n                .font(.bitchatSystem(size: 12, design: .monospaced))\n                .foregroundColor(.secondary)\n                .fixedSize(horizontal: false, vertical: true)\n            if manager.state == .noRelays {\n                Text(Strings.relaysPaused)\n                    .font(.bitchatSystem(size: 11, design: .monospaced))\n                    .foregroundColor(.secondary)\n            }\n        }\n        .padding(.horizontal, 16)\n        .padding(.top, 16)\n        .padding(.bottom, 12)\n        .background(backgroundColor)\n    }\n\n    private func headerTitle(for count: Int) -> String {\n        String(\n            format: String(localized: \"location_notes.header\", comment: \"Header displaying the geohash and localized note count\"),\n            locale: .current,\n            \"\\(geohash) ± 1\", count\n        )\n    }\n\n    private var notesContent: some View {\n        LazyVStack(alignment: .leading, spacing: 12) {\n            if manager.state == .noRelays {\n                noRelaysRow\n            } else if manager.state == .loading && !manager.initialLoadComplete {\n                loadingRow\n            } else if manager.notes.isEmpty {\n                emptyRow\n            } else {\n                ForEach(manager.notes) { note in\n                    noteRow(note)\n                }\n            }\n\n            if let error = manager.errorMessage, manager.state != .noRelays {\n                errorRow(message: error)\n            }\n        }\n        .padding(.horizontal, 16)\n        .padding(.vertical, 8)\n    }\n\n    private func noteRow(_ note: LocationNotesManager.Note) -> some View {\n        let baseName = note.displayName.split(separator: \"#\", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName\n        let ts = timestampText(for: note.createdAt)\n        return VStack(alignment: .leading, spacing: 2) {\n            HStack(spacing: 6) {\n                Text(verbatim: \"@\\(baseName)\")\n                    .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))\n                if !ts.isEmpty {\n                    Text(ts)\n                        .font(.bitchatSystem(size: 11, design: .monospaced))\n                        .foregroundColor(.secondary)\n                }\n                Spacer()\n            }\n            Text(note.content)\n                .font(.bitchatSystem(size: 14, design: .monospaced))\n                .fixedSize(horizontal: false, vertical: true)\n        }\n        .padding(.vertical, 4)\n    }\n\n    private var noRelaysRow: some View {\n        VStack(alignment: .leading, spacing: 4) {\n            Text(Strings.noRelaysNearby)\n                .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))\n            Text(Strings.relaysRetryHint)\n                .font(.bitchatSystem(size: 12, design: .monospaced))\n                .foregroundColor(.secondary)\n            Button(Strings.retry) { manager.refresh() }\n                .font(.bitchatSystem(size: 12, design: .monospaced))\n                .buttonStyle(.plain)\n        }\n        .padding(.vertical, 6)\n    }\n\n    private var loadingRow: some View {\n        HStack(spacing: 10) {\n            ProgressView()\n            Text(Strings.loadingNotes)\n                .font(.bitchatSystem(size: 12, design: .monospaced))\n                .foregroundColor(.secondary)\n            Spacer()\n        }\n        .padding(.vertical, 8)\n    }\n\n    private var emptyRow: some View {\n        VStack(alignment: .leading, spacing: 4) {\n            Text(Strings.emptyTitle)\n                .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))\n            Text(Strings.emptySubtitle)\n                .font(.bitchatSystem(size: 12, design: .monospaced))\n                .foregroundColor(.secondary)\n        }\n        .padding(.vertical, 6)\n    }\n\n    private func errorRow(message: String) -> some View {\n        VStack(alignment: .leading, spacing: 4) {\n            HStack(spacing: 6) {\n                Image(systemName: \"exclamationmark.triangle.fill\")\n                    .font(.bitchatSystem(size: 12, design: .monospaced))\n                Text(message)\n                    .font(.bitchatSystem(size: 12, design: .monospaced))\n                Spacer()\n            }\n            Button(Strings.dismissError) { manager.clearError() }\n                .font(.bitchatSystem(size: 12, design: .monospaced))\n                .buttonStyle(.plain)\n        }\n        .padding(.vertical, 6)\n    }\n\n    private var inputSection: some View {\n        HStack(alignment: .top, spacing: 10) {\n            TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)\n                .textFieldStyle(.plain)\n                .font(.bitchatSystem(size: 14, design: .monospaced))\n                .lineLimit(maxDraftLines, reservesSpace: true)\n                .padding(.vertical, 6)\n            Button(action: send) {\n                Image(systemName: \"arrow.up.circle.fill\")\n                    .font(.bitchatSystem(size: 20))\n                    .foregroundColor(sendButtonEnabled ? accentGreen : .secondary)\n            }\n            .padding(.top, 2)\n            .buttonStyle(.plain)\n            .disabled(!sendButtonEnabled)\n        }\n        .padding(.horizontal, 16)\n        .padding(.vertical, 14)\n        .background(backgroundColor)\n        .overlay(Divider(), alignment: .top)\n    }\n\n    private func send() {\n        let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !content.isEmpty else { return }\n        manager.send(content: content, nickname: viewModel.nickname)\n        draft = \"\"\n    }\n\n    private var sendButtonEnabled: Bool {\n        !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays\n    }\n\n    // MARK: - Timestamp Formatting\n    private func timestampText(for date: Date) -> String {\n        let now = Date()\n        if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {\n            let rel = Self.relativeFormatter.string(from: date, to: now) ?? \"\"\n            return rel.isEmpty ? \"\" : \"\\(rel) ago\"\n        } else {\n            let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)\n            let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter\n            return fmt.string(from: date)\n        }\n    }\n\n    private static let relativeFormatter: DateComponentsFormatter = {\n        let f = DateComponentsFormatter()\n        f.allowedUnits = [.day, .hour, .minute]\n        f.maximumUnitCount = 1\n        f.unitsStyle = .abbreviated\n        f.collapsesLargestUnit = true\n        return f\n    }()\n\n    private static let absDateFormatter: DateFormatter = {\n        let f = DateFormatter()\n        f.setLocalizedDateFormatFromTemplate(\"MMM d\")\n        return f\n    }()\n\n    private static let absDateYearFormatter: DateFormatter = {\n        let f = DateFormatter()\n        f.setLocalizedDateFormatFromTemplate(\"MMM d, y\")\n        return f\n    }()\n}\n"
  },
  {
    "path": "bitchat/Views/Media/BlockRevealImageView.swift",
    "content": "import SwiftUI\n\n#if os(iOS)\nimport UIKit\nprivate typealias PlatformImage = UIImage\n#else\nimport AppKit\nprivate typealias PlatformImage = NSImage\n#endif\n\nstruct BlockRevealImageView: View {\n    private let url: URL\n    private let revealProgress: Double?\n    private let isSending: Bool\n    private let onCancel: (() -> Void)?\n    private let initiallyBlurred: Bool\n    private let onOpen: (() -> Void)?\n    private let onDelete: (() -> Void)?\n\n    @State private var platformImage: PlatformImage?\n    @State private var aspectRatio: CGFloat = 1\n    @State private var isBlurred: Bool = false\n\n    init(\n        url: URL,\n        revealProgress: Double?,\n        isSending: Bool,\n        onCancel: (() -> Void)?,\n        initiallyBlurred: Bool = false,\n        onOpen: (() -> Void)? = nil,\n        onDelete: (() -> Void)? = nil\n    ) {\n        self.url = url\n        self.revealProgress = revealProgress\n        self.isSending = isSending\n        self.onCancel = onCancel\n        self.initiallyBlurred = initiallyBlurred\n        self.onOpen = onOpen\n        self.onDelete = onDelete\n    }\n\n    private var fraction: Double {\n        guard let revealProgress = revealProgress else { return 1 }\n        return max(0, min(1, revealProgress))\n    }\n\n    var body: some View {\n        ZStack(alignment: .topTrailing) {\n            if let image = platformImage {\n                Image(platformImage: image)\n                    .resizable()\n                    .aspectRatio(aspectRatio, contentMode: .fit)\n                    .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))\n                    .overlay(\n                        RoundedRectangle(cornerRadius: 16, style: .continuous)\n                            .stroke(Color.gray.opacity(0.2), lineWidth: 1)\n                    )\n                    .mask(\n                        BlockRevealMask(\n                            fraction: fraction,\n                            columns: 24,\n                            rows: 16\n                        )\n                        .animation(.easeOut(duration: 0.2), value: fraction)\n                    )\n                    .blur(radius: isBlurred ? 20 : 0)\n                    .overlay {\n                        if isBlurred {\n                            RoundedRectangle(cornerRadius: 16, style: .continuous)\n                                .fill(Color.black.opacity(0.35))\n                                .overlay(\n                                    Image(systemName: \"eye.slash.fill\")\n                                        .font(.bitchatSystem(size: 24, weight: .semibold))\n                                        .foregroundColor(.white.opacity(0.85))\n                                )\n                        }\n                    }\n            } else {\n                RoundedRectangle(cornerRadius: 16, style: .continuous)\n                    .fill(Color.gray.opacity(0.2))\n                    .frame(height: 200)\n                    .overlay(\n                        ProgressView()\n                            .progressViewStyle(.circular)\n                    )\n            }\n\n            if let onCancel = onCancel, isSending {\n                Button(action: onCancel) {\n                    Image(systemName: \"xmark\")\n                        .font(.bitchatSystem(size: 12, weight: .bold))\n                        .padding(8)\n                        .background(Circle().fill(Color.black.opacity(0.7)))\n                        .foregroundColor(.white)\n                        .padding(8)\n                }\n                .buttonStyle(.plain)\n            }\n        }\n        .onAppear {\n            isBlurred = initiallyBlurred\n            loadImage()\n        }\n        .onChange(of: url) { _ in\n            isBlurred = initiallyBlurred\n            loadImage()\n        }\n        .gesture(mainGesture)\n    }\n\n    private func loadImage() {\n        DispatchQueue.global(qos: .userInitiated).async {\n            #if os(iOS)\n            guard let image = UIImage(contentsOfFile: url.path) else { return }\n            #else\n            guard let image = NSImage(contentsOf: url) else { return }\n            #endif\n            let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1\n            DispatchQueue.main.async {\n                self.platformImage = image\n                self.aspectRatio = ratio\n            }\n        }\n    }\n\n    private var mainGesture: some Gesture {\n        let doubleTap = TapGesture(count: 2).onEnded {\n            guard !isSending else { return }\n            onDelete?()\n        }\n        let singleTap = TapGesture().onEnded {\n            guard !isSending else { return }\n            if isBlurred {\n                withAnimation(.easeOut(duration: 0.2)) {\n                    isBlurred = false\n                }\n            } else {\n                onOpen?()\n            }\n        }\n        let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in\n            guard !isSending else { return }\n            let horizontal = value.translation.width\n            let vertical = value.translation.height\n            guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }\n            if !isBlurred {\n                withAnimation(.easeInOut(duration: 0.2)) {\n                    isBlurred = true\n                }\n            }\n        }\n        return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)\n    }\n}\n\nprivate struct BlockRevealMask: Shape {\n    let fraction: Double\n    let columns: Int\n    let rows: Int\n\n    func path(in rect: CGRect) -> Path {\n        var path = Path()\n        guard fraction > 0, columns > 0, rows > 0 else { return path }\n        let totalBlocks = columns * rows\n        let revealCount = max(0, min(totalBlocks, Int(ceil(fraction * Double(totalBlocks)))))\n        guard revealCount > 0 else { return path }\n        let blockWidth = rect.width / CGFloat(columns)\n        let blockHeight = rect.height / CGFloat(rows)\n        var remaining = revealCount\n        for row in 0..<rows {\n            for column in 0..<columns {\n                if remaining <= 0 { return path }\n                let x = CGFloat(column) * blockWidth\n                let y = CGFloat(row) * blockHeight\n                path.addRect(CGRect(x: x, y: y, width: blockWidth, height: blockHeight))\n                remaining -= 1\n            }\n        }\n        return path\n    }\n}\n\nprivate extension Image {\n    init(platformImage: PlatformImage) {\n        #if os(iOS)\n        self.init(uiImage: platformImage)\n        #else\n        self.init(nsImage: platformImage)\n        #endif\n    }\n}\n"
  },
  {
    "path": "bitchat/Views/Media/VoiceNoteView.swift",
    "content": "import SwiftUI\nimport AVFoundation\n\nstruct VoiceNoteView: View {\n    private let url: URL\n    private let isSending: Bool\n    private let sendProgress: Double?\n    private let onCancel: (() -> Void)?\n\n    @Environment(\\.colorScheme) private var colorScheme\n    @StateObject private var playback: VoiceNotePlaybackController\n    @State private var waveform: [Float] = []\n\n    init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) {\n        self.url = url\n        self.isSending = isSending\n        self.sendProgress = sendProgress\n        self.onCancel = onCancel\n        _playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url))\n    }\n\n    private var samples: [Float] {\n        if waveform.isEmpty {\n            return Array(repeating: 0.25, count: 64)\n        }\n        return waveform\n    }\n\n    private var backgroundColor: Color {\n        colorScheme == .dark ? Color.black.opacity(0.6) : Color.white\n    }\n\n    private var borderColor: Color {\n        colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)\n    }\n\n    private var durationText: String {\n        let duration = playback.duration\n        guard duration.isFinite, duration > 0 else { return \"--:--\" }\n        let minutes = Int(duration) / 60\n        let seconds = Int(duration) % 60\n        return String(format: \"%02d:%02d\", minutes, seconds)\n    }\n\n    private var currentText: String {\n        let current = playback.currentTime\n        guard current.isFinite, current > 0 else { return \"00:00\" }\n        let minutes = Int(current) / 60\n        let seconds = Int(current) % 60\n        return String(format: \"%02d:%02d\", minutes, seconds)\n    }\n\n    private var playbackLabel: String {\n        playback.isPlaying ? currentText + \"/\" + durationText : durationText\n    }\n\n    var body: some View {\n        HStack(spacing: 12) {\n            Button(action: playback.togglePlayback) {\n                Image(systemName: playback.isPlaying ? \"pause.fill\" : \"play.fill\")\n                    .foregroundColor(.white)\n                    .frame(width: 36, height: 36)\n                    .background(Circle().fill(Color.green))\n            }\n            .buttonStyle(.plain)\n\n            WaveformView(\n                samples: samples,\n                playbackProgress: playback.progress,\n                sendProgress: sendProgress,\n                onSeek: { fraction in\n                    playback.seek(to: fraction)\n                },\n                isInteractive: playback.isPlaying\n            )\n\n            Text(playbackLabel)\n                .font(.bitchatSystem(size: 13, design: .monospaced))\n                .foregroundColor(Color.secondary)\n\n            if let onCancel = onCancel, isSending {\n                Button(action: onCancel) {\n                    Image(systemName: \"xmark\")\n                        .font(.bitchatSystem(size: 12, weight: .bold))\n                        .frame(width: 28, height: 28)\n                        .background(Circle().fill(Color.red.opacity(0.9)))\n                        .foregroundColor(.white)\n                }\n                .buttonStyle(.plain)\n            }\n        }\n        .padding(12)\n        .background(\n            RoundedRectangle(cornerRadius: 14)\n                .fill(backgroundColor)\n                .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.3 : 0.1), radius: 6, x: 0, y: 2)\n        )\n        .overlay(\n            RoundedRectangle(cornerRadius: 14)\n                .stroke(borderColor, lineWidth: 1)\n        )\n        .task {\n            // Defer loading to let UI settle after view appears\n            try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s\n            playback.loadDuration()\n            await withCheckedContinuation { continuation in\n                WaveformCache.shared.waveform(for: url, completion: { bins in\n                    waveform = bins\n                    continuation.resume()\n                })\n            }\n        }\n        .onChange(of: url) { newValue in\n            WaveformCache.shared.waveform(for: newValue, completion: { bins in\n                self.waveform = bins\n            })\n            playback.replaceURL(newValue)\n        }\n        .onDisappear {\n            playback.stop()\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Views/Media/WaveformView.swift",
    "content": "import SwiftUI\n\nstruct WaveformView: View {\n    let samples: [Float]\n    let playbackProgress: Double\n    let sendProgress: Double?\n    let onSeek: ((Double) -> Void)?\n    let isInteractive: Bool\n\n    private var clampedPlayback: Double {\n        max(0, min(1, playbackProgress))\n    }\n\n    private var clampedSend: Double? {\n        guard let sendProgress = sendProgress else { return nil }\n        return max(0, min(1, sendProgress))\n    }\n\n    var body: some View {\n        GeometryReader { geometry in\n            ZStack {\n                Canvas { context, size in\n                    guard !samples.isEmpty else { return }\n                    let width = max(size.width, 1)\n                    let height = max(size.height, 1)\n                    let barWidth = max(width / CGFloat(samples.count), 1)\n                    for (index, sample) in samples.enumerated() {\n                        let normalized = max(0, min(sample, 1))\n                        let barHeight = CGFloat(normalized) * height\n                        let originX = CGFloat(index) * barWidth\n                        let rect = CGRect(\n                            x: originX,\n                            y: (height - barHeight) / 2,\n                            width: max(barWidth * 0.7, 1),\n                            height: barHeight\n                        )\n                        let binPosition = Double(index) / Double(samples.count)\n                        let color: Color\n                        if binPosition <= clampedPlayback {\n                            color = Color.green\n                        } else if let send = clampedSend, binPosition <= send {\n                            color = Color.blue\n                        } else {\n                            color = Color.gray.opacity(0.35)\n                        }\n                        context.fill(Path(rect), with: .color(color))\n                    }\n                }\n                .frame(width: geometry.size.width, height: geometry.size.height)\n\n                if isInteractive, let onSeek = onSeek {\n                    Color.clear\n                        .contentShape(Rectangle())\n                        .gesture(\n                            DragGesture(minimumDistance: 0)\n                                .onEnded { value in\n                                    guard geometry.size.width > 0 else { return }\n                                    let fraction = max(0, min(1, value.location.x / geometry.size.width))\n                                    onSeek(fraction)\n                                }\n                        )\n                }\n            }\n        }\n        .frame(height: 48)\n    }\n}\n"
  },
  {
    "path": "bitchat/Views/MeshPeerList.swift",
    "content": "import SwiftUI\n\nstruct MeshPeerList: View {\n    @ObservedObject var viewModel: ChatViewModel\n    let textColor: Color\n    let secondaryTextColor: Color\n    let onTapPeer: (PeerID) -> Void\n    let onToggleFavorite: (PeerID) -> Void\n    let onShowFingerprint: (PeerID) -> Void\n    @Environment(\\.colorScheme) var colorScheme\n\n    @State private var orderedIDs: [String] = []\n\n    private enum Strings {\n        static let noneNearby: LocalizedStringKey = \"geohash_people.none_nearby\"\n        static let blockedTooltip = String(localized: \"geohash_people.tooltip.blocked\", comment: \"Tooltip shown next to a blocked peer indicator\")\n        static let newMessagesTooltip = String(localized: \"mesh_peers.tooltip.new_messages\", comment: \"Tooltip for the unread messages indicator\")\n    }\n\n    var body: some View {\n        let myPeerID = viewModel.meshService.myPeerID\n        let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in\n            let isMe = peer.peerID == myPeerID\n            let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID)\n            let enc = viewModel.getEncryptionStatus(for: peer.peerID)\n            return (peer, isMe, hasUnread, enc)\n        }\n        // Stable visual order without mutating state here\n        let currentIDs = mapped.map { $0.peer.peerID.id }\n        let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }\n        let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in\n            mapped.first(where: { $0.peer.peerID.id == id })\n        }\n        \n        if viewModel.allPeers.isEmpty {\n            VStack(alignment: .leading, spacing: 0) {\n                Text(Strings.noneNearby)\n                    .font(.bitchatSystem(size: 14, design: .monospaced))\n                    .foregroundColor(secondaryTextColor)\n                    .padding(.horizontal)\n                    .padding(.top, 12)\n            }\n        } else {\n            VStack(alignment: .leading, spacing: 0) {\n                ForEach(0..<peers.count, id: \\.self) { idx in\n                    let item = peers[idx]\n                    let peer = item.peer\n                    let isMe = item.isMe\n                    HStack(spacing: 4) {\n                        let assigned = viewModel.colorForMeshPeer(id: peer.peerID, isDark: colorScheme == .dark)\n                        let baseColor = isMe ? Color.orange : assigned\n                        if isMe {\n                            Image(systemName: \"person.fill\")\n                                .font(.bitchatSystem(size: 10))\n                                .foregroundColor(baseColor)\n                        } else if peer.isConnected {\n                            // Mesh-connected peer: radio icon\n                            Image(systemName: \"antenna.radiowaves.left.and.right\")\n                                .font(.bitchatSystem(size: 10))\n                                .foregroundColor(baseColor)\n                        } else if peer.isReachable {\n                            // Mesh-reachable (relayed): point.3 icon\n                            Image(systemName: \"point.3.filled.connected.trianglepath.dotted\")\n                                .font(.bitchatSystem(size: 10))\n                                .foregroundColor(baseColor)\n                        } else if peer.isMutualFavorite {\n                            // Mutual favorite reachable via Nostr: globe icon (purple)\n                            Image(systemName: \"globe\")\n                                .font(.bitchatSystem(size: 10))\n                                .foregroundColor(.purple)\n                        } else {\n                            // Fallback icon for others (dimmed)\n                            Image(systemName: \"person\")\n                                .font(.bitchatSystem(size: 10))\n                                .foregroundColor(secondaryTextColor)\n                        }\n\n                        let displayName = isMe ? viewModel.nickname : peer.nickname\n                        let (base, suffix) = displayName.splitSuffix()\n                        HStack(spacing: 0) {\n                            Text(base)\n                                .font(.bitchatSystem(size: 14, design: .monospaced))\n                                .foregroundColor(baseColor)\n                            if !suffix.isEmpty {\n                                let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)\n                                Text(suffix)\n                                    .font(.bitchatSystem(size: 14, design: .monospaced))\n                                    .foregroundColor(suffixColor)\n                            }\n                        }\n\n                        if !isMe, viewModel.isPeerBlocked(peer.peerID) {\n                            Image(systemName: \"nosign\")\n                                .font(.bitchatSystem(size: 10))\n                                .foregroundColor(.red)\n                                .help(Strings.blockedTooltip)\n                        }\n\n                        if !isMe {\n                            if peer.isConnected {\n                                if let icon = item.enc.icon {\n                                    Image(systemName: icon)\n                                        .font(.bitchatSystem(size: 10))\n                                        .foregroundColor(baseColor)\n                                }\n                            } else {\n                                // Offline: prefer showing verified badge from persisted fingerprints\n                                if let fp = viewModel.getFingerprint(for: peer.peerID),\n                                   viewModel.verifiedFingerprints.contains(fp) {\n                                    Image(systemName: \"checkmark.seal.fill\")\n                                        .font(.bitchatSystem(size: 10))\n                                        .foregroundColor(baseColor)\n                                } else if let icon = item.enc.icon {\n                                    // Fallback to whatever status says (likely lock if we had a past session)\n                                    Image(systemName: icon)\n                                        .font(.bitchatSystem(size: 10))\n                                        .foregroundColor(baseColor)\n                                }\n                            }\n                        }\n\n                        Spacer()\n\n                        // Unread message indicator for this peer\n                        if !isMe, item.hasUnread {\n                            Image(systemName: \"envelope.fill\")\n                                .font(.bitchatSystem(size: 10))\n                                .foregroundColor(.orange)\n                                .help(Strings.newMessagesTooltip)\n                        }\n\n                        if !isMe {\n                            Button(action: { onToggleFavorite(peer.peerID) }) {\n                                Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? \"star.fill\" : \"star\")\n                                    .font(.bitchatSystem(size: 12))\n                                    .foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)\n                            }\n                            .buttonStyle(.plain)\n                        }\n                    }\n                    .padding(.horizontal)\n                    .padding(.vertical, 4)\n                    .padding(.top, idx == 0 ? 10 : 0)\n                    .contentShape(Rectangle())\n                    .onTapGesture { if !isMe { onTapPeer(peer.peerID) } }\n                    .onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }\n                }\n            }\n            // Seed and update order outside result builder\n            .onAppear {\n                let currentIDs = mapped.map { $0.peer.peerID.id }\n                orderedIDs = currentIDs\n            }\n            .onChange(of: mapped.map { $0.peer.peerID.id }) { ids in\n                var newOrder = orderedIDs\n                newOrder.removeAll { !ids.contains($0) }\n                for id in ids where !newOrder.contains(id) { newOrder.append(id) }\n                if newOrder != orderedIDs { orderedIDs = newOrder }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bitchat/Views/MessageTextHelpers.swift",
    "content": "//\n// MessageTextHelpers.swift\n// Shared text parsing helpers for message rendering.\n//\n\nimport Foundation\n\nextension String {\n    // Detect if there is an extremely long token (no whitespace/newlines) that could break layout\n    func hasVeryLongToken(threshold: Int) -> Bool {\n        var current = 0\n        for ch in self {\n            if ch.isWhitespace || ch.isNewline {\n                if current >= threshold { return true }\n                current = 0\n            } else {\n                current += 1\n                if current >= threshold { return true }\n            }\n        }\n        return current >= threshold\n    }\n\n    // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.\n    func extractCashuLinks(max: Int = 3) -> [String] {\n        let regex = MessageFormattingEngine.Patterns.cashu\n        let ns = self as NSString\n        let range = NSRange(location: 0, length: ns.length)\n        var found: [String] = []\n        for m in regex.matches(in: self, range: range) {\n            if m.numberOfRanges > 0 {\n                let token = ns.substring(with: m.range(at: 0))\n                let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: \"-_\"))) ?? token\n                found.append(\"cashu:\\(enc)\")\n                if found.count >= max { break }\n            }\n        }\n        return found\n    }\n\n    // Extract Lightning payloads (scheme, BOLT11, LNURL). Returned as lightning:<payload>\n    func extractLightningLinks(max: Int = 3) -> [String] {\n        var results: [String] = []\n        let ns = self as NSString\n        let full = NSRange(location: 0, length: ns.length)\n        // lightning: scheme\n        for m in MessageFormattingEngine.Patterns.lightningScheme.matches(in: self, range: full) {\n            let s = ns.substring(with: m.range(at: 0))\n            results.append(s)\n            if results.count >= max { return results }\n        }\n        // BOLT11\n        for m in MessageFormattingEngine.Patterns.bolt11.matches(in: self, range: full) {\n            let s = ns.substring(with: m.range(at: 0))\n            results.append(\"lightning:\\(s)\")\n            if results.count >= max { return results }\n        }\n        // LNURL bech32\n        for m in MessageFormattingEngine.Patterns.lnurl.matches(in: self, range: full) {\n            let s = ns.substring(with: m.range(at: 0))\n            results.append(\"lightning:\\(s)\")\n            if results.count >= max { return results }\n        }\n        return results\n    }\n}\n"
  },
  {
    "path": "bitchat/Views/VerificationViews.swift",
    "content": "import SwiftUI\nimport CoreImage\nimport CoreImage.CIFilterBuiltins\n#if os(iOS)\nimport UIKit\n#else\nimport AppKit\n#endif\n\n/// Placeholder view to display the user's verification QR payload as text.\nstruct MyQRView: View {\n    let qrString: String\n    @Environment(\\.colorScheme) var colorScheme\n    private var boxColor: Color { Color.gray.opacity(0.1) }\n\n    private enum Strings {\n        static let title: LocalizedStringKey = \"verification.my_qr.title\"\n        static let accessibilityLabel = String(localized: \"verification.my_qr.accessibility_label\", comment: \"Accessibility label describing the verification QR code\")\n    }\n\n    var body: some View {\n        VStack(spacing: 12) {\n            Text(Strings.title)\n                .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))\n\n            VStack(spacing: 10) {\n                QRCodeImage(data: qrString, size: 240)\n                    .accessibilityLabel(Strings.accessibilityLabel)\n\n                // Non-scrolling, fully visible URL (wraps across lines)\n                Text(qrString)\n                    .font(.bitchatSystem(size: 11, design: .monospaced))\n                    .textSelection(.enabled)\n                    .multilineTextAlignment(.leading)\n                    .fixedSize(horizontal: false, vertical: true)\n                    .padding(8)\n                    .background(boxColor)\n                    .cornerRadius(8)\n            }\n            .padding()\n            .frame(maxWidth: .infinity)\n            .background(boxColor)\n            .cornerRadius(8)\n        }\n        .padding()\n    }\n}\n\n// Render a QR code image for a given string using CoreImage\nstruct QRCodeImage: View {\n    let data: String\n    let size: CGFloat\n\n    private let context = CIContext()\n    private let filter = CIFilter.qrCodeGenerator()\n\n    private enum Strings {\n        static let unavailable: LocalizedStringKey = \"verification.my_qr.unavailable\"\n    }\n\n    var body: some View {\n        Group {\n            if let image = generateImage() {\n                ImageWrapper(image: image)\n                    .frame(width: size, height: size)\n            } else {\n                RoundedRectangle(cornerRadius: 8)\n                    .stroke(Color.gray.opacity(0.5), lineWidth: 1)\n                    .frame(width: size, height: size)\n                    .overlay(\n                        Text(Strings.unavailable)\n                            .font(.bitchatSystem(size: 12, design: .monospaced))\n                            .foregroundColor(.gray)\n                    )\n            }\n        }\n    }\n\n    private func generateImage() -> CGImage? {\n        let inputData = Data(data.utf8)\n        filter.message = inputData\n        filter.correctionLevel = \"M\"\n        guard let outputImage = filter.outputImage else { return nil }\n        let scale = max(1, Int(size / 32))\n        let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale)))\n        return context.createCGImage(transformed, from: transformed.extent)\n    }\n}\n\n// Platform-specific wrapper to display CGImage in SwiftUI\nstruct ImageWrapper: View {\n    let image: CGImage\n    var body: some View {\n        #if os(iOS)\n        let ui = UIImage(cgImage: image)\n        return Image(uiImage: ui)\n            .interpolation(.none)\n            .resizable()\n        #else\n        let ns = NSImage(cgImage: image, size: .zero)\n        return Image(nsImage: ns)\n            .interpolation(.none)\n            .resizable()\n        #endif\n    }\n}\n\n/// Placeholder scanner UI; real camera scanning will be added later.\nstruct QRScanView: View {\n    @EnvironmentObject var viewModel: ChatViewModel\n    var isActive: Bool = true\n    var onSuccess: (() -> Void)? = nil  // Called when verification succeeds\n    @State private var input = \"\"\n    @State private var result: String = \"\" // not shown for iOS scanner\n    @State private var lastValid: String = \"\"\n\n    private enum Strings {\n        static let pastePrompt: LocalizedStringKey = \"verification.scan.paste_prompt\"\n        static let validate: LocalizedStringKey = \"verification.scan.validate\"\n        static func requested(_ nickname: String) -> String {\n            String(\n                format: String(localized: \"verification.scan.status.requested\", comment: \"Status text when verification is requested for a nickname\"),\n                locale: .current,\n                nickname\n            )\n        }\n        static let notFound = String(localized: \"verification.scan.status.no_peer\", comment: \"Status when no matching peer is found for a verification request\")\n        static let invalid = String(localized: \"verification.scan.status.invalid\", comment: \"Status when a scanned QR payload is invalid\")\n    }\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            #if os(iOS)\n            CameraScannerView(isActive: isActive) { code in\n                // Deduplicate: ignore if we just processed this exact QR code\n                guard code != lastValid else { return }\n\n                if let qr = VerificationService.shared.verifyScannedQR(code) {\n                    let ok = viewModel.beginQRVerification(with: qr)\n                    if ok {\n                        // Successfully initiated verification; remember this QR to prevent re-scanning\n                        lastValid = code\n                        // Close scanner and return to \"My QR\" view\n                        onSuccess?()\n                    }\n                    // If !ok, peer not found or already pending - don't set lastValid so user can retry\n                } else {\n                    // ignore invalid reads; continue scanning\n                }\n            }\n            .frame(height: 260)\n            .clipShape(RoundedRectangle(cornerRadius: 8))\n            #else\n            Text(Strings.pastePrompt)\n                .font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))\n            TextEditor(text: $input)\n                .frame(height: 100)\n                .border(Color.gray.opacity(0.4))\n            Button(Strings.validate) {\n                // Deduplicate: ignore if we just processed this exact QR\n                guard input != lastValid else {\n                    result = Strings.requested(\"\")  // Already processed\n                    return\n                }\n\n                if let qr = VerificationService.shared.verifyScannedQR(input) {\n                    let ok = viewModel.beginQRVerification(with: qr)\n                    if ok {\n                        result = Strings.requested(qr.nickname)\n                        lastValid = input\n                        // Close scanner and return to \"My QR\" view\n                        onSuccess?()\n                    } else {\n                        result = Strings.notFound\n                    }\n                } else {\n                    result = Strings.invalid\n                }\n            }\n            .buttonStyle(.bordered)\n            #endif\n            // No status text under camera per design\n            Spacer()\n        }\n        .padding()\n    }\n}\n\n#if os(iOS)\nimport AVFoundation\n\nstruct CameraScannerView: UIViewRepresentable {\n    typealias UIViewType = PreviewView\n    var isActive: Bool\n    var onCode: (String) -> Void\n\n    func makeUIView(context: Context) -> PreviewView {\n        let view = PreviewView()\n        context.coordinator.setup(sessionOwner: view, onCode: onCode)\n        context.coordinator.setActive(isActive)\n        return view\n    }\n\n    func updateUIView(_ uiView: PreviewView, context: Context) {\n        context.coordinator.setActive(isActive)\n    }\n\n    func makeCoordinator() -> Coordinator { Coordinator() }\n\n    final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {\n        private var onCode: ((String) -> Void)?\n        private weak var owner: PreviewView?\n        private let session = AVCaptureSession()\n        private var isRunning = false\n        private var permissionGranted = false\n        private var desiredActive = false\n\n        func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {\n            self.owner = sessionOwner\n            self.onCode = onCode\n            session.beginConfiguration()\n            session.sessionPreset = .high\n            guard let device = AVCaptureDevice.default(for: .video),\n                  let input = try? AVCaptureDeviceInput(device: device),\n                  session.canAddInput(input) else { return }\n            session.addInput(input)\n            let output = AVCaptureMetadataOutput()\n            guard session.canAddOutput(output) else { return }\n            session.addOutput(output)\n            output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)\n            if output.availableMetadataObjectTypes.contains(.qr) {\n                output.metadataObjectTypes = [.qr]\n            }\n            session.commitConfiguration()\n            sessionOwner.videoPreviewLayer.session = session\n            // Request permission and start\n            AVCaptureDevice.requestAccess(for: .video) { granted in\n                self.permissionGranted = granted\n                if granted && self.desiredActive && !self.isRunning {\n                    self.setActive(true)\n                }\n            }\n        }\n\n        func setActive(_ active: Bool) {\n            desiredActive = active\n            guard permissionGranted else { return }\n            if active && !isRunning {\n                isRunning = true\n                DispatchQueue.global(qos: .userInitiated).async {\n                    if !self.session.isRunning { self.session.startRunning() }\n                }\n            } else if !active && isRunning {\n                isRunning = false\n                DispatchQueue.global(qos: .userInitiated).async {\n                    if self.session.isRunning { self.session.stopRunning() }\n                }\n            }\n        }\n\n        func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {\n            for obj in metadataObjects {\n                guard let m = obj as? AVMetadataMachineReadableCodeObject,\n                      m.type == .qr,\n                      let str = m.stringValue else { continue }\n                onCode?(str)\n            }\n        }\n    }\n\n    final class PreviewView: UIView {\n        override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }\n        var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }\n        override init(frame: CGRect) {\n            super.init(frame: frame)\n            videoPreviewLayer.videoGravity = .resizeAspectFill\n        }\n        required init?(coder: NSCoder) { fatalError(\"init(coder:) has not been implemented\") }\n    }\n}\n#endif\n\n// Combined sheet: shows my QR by default with a button to scan instead\nstruct VerificationSheetView: View {\n    @EnvironmentObject var viewModel: ChatViewModel\n    @Binding var isPresented: Bool\n    @State private var showingScanner = false\n    @Environment(\\.colorScheme) var colorScheme\n\n    private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white }\n    private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }\n    private var boxColor: Color { Color.gray.opacity(0.1) }\n\n    private func myQRString() -> String {\n        let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub\n        return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? \"\"\n    }\n\n    var body: some View {\n        VStack(spacing: 0) {\n            // Top header (always at top)\n            HStack {\n                Text(\"verification.sheet.title\")\n                    .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))\n                    .foregroundColor(accentColor)\n                Spacer()\n                Button(action: {\n                    showingScanner = false\n                    isPresented = false\n                }) {\n                    Image(systemName: \"xmark\")\n                        .font(.bitchatSystem(size: 14, weight: .semibold))\n                        .foregroundColor(accentColor)\n                }\n                .buttonStyle(.plain)\n            }\n            .padding(.horizontal, 16)\n            .padding(.top, 12)\n            .padding(.bottom, 8)\n\n            Divider()\n\n            // Content area\n            Group {\n                if showingScanner {\n                    VStack(alignment: .leading, spacing: 12) {\n                        Text(\"verification.scan.prompt_friend\")\n                            .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))\n                            .frame(maxWidth: .infinity)\n                            .multilineTextAlignment(.center)\n                            .foregroundColor(accentColor)\n                        #if os(iOS)\n                        QRScanView(isActive: showingScanner, onSuccess: {\n                            showingScanner = false\n                        })\n                            .environmentObject(viewModel)\n                            .frame(height: 280)\n                            .clipShape(RoundedRectangle(cornerRadius: 10))\n                        #else\n                        QRScanView(onSuccess: {\n                            showingScanner = false\n                        })\n                            .environmentObject(viewModel)\n                        #endif\n                    }\n                    .padding()\n                    .frame(maxWidth: .infinity)\n                    .background(boxColor)\n                    .cornerRadius(8)\n                } else {\n                    let qr = myQRString()\n                    MyQRView(qrString: qr)\n                }\n            }\n            .padding(16)\n            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)\n\n            // Centered controls moved up\n            VStack(spacing: 10) {\n                if showingScanner {\n                    Button(action: { showingScanner = false }) {\n                        Label(\"show my qr\", systemImage: \"qrcode\")\n                            .font(.bitchatSystem(size: 13, design: .monospaced))\n                    }\n                    .buttonStyle(.bordered)\n                } else {\n                    Button(action: { showingScanner = true }) {\n                        Label(\"scan someone else's qr\", systemImage: \"camera.viewfinder\")\n                            .font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced))\n                    }\n                    .buttonStyle(.bordered)\n                    .tint(.gray)\n                }\n\n                // Optional: Remove verification for selected peer (if verified)\n                if let pid = viewModel.selectedPrivateChatPeer,\n                   let fp = viewModel.getFingerprint(for: pid),\n                   viewModel.verifiedFingerprints.contains(fp) {\n                    Button(action: { viewModel.unverifyFingerprint(for: pid) }) {\n                        Label(\"remove verification\", systemImage: \"minus.circle\")\n                            .font(.bitchatSystem(size: 12, design: .monospaced))\n                    }\n                    .buttonStyle(.bordered)\n                    .tint(.gray)\n                }\n            }\n            .frame(maxWidth: .infinity)\n            .padding(.vertical, 14)\n        }\n        .background(backgroundColor)\n        .onDisappear { showingScanner = false }\n    }\n}\n"
  },
  {
    "path": "bitchat/_PreviewHelpers/BitchatMessage+Preview.swift",
    "content": "//\n// BitchatMessage+Preview.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\nextension BitchatMessage {\n    static var preview: BitchatMessage {\n        BitchatMessage(\n            id: UUID().uuidString,\n            sender: \"John Doe\",\n            content: \"Hello\",\n            timestamp: Date(),\n            isRelay: false,\n            originalSender: nil,\n            isPrivate: false,\n            recipientNickname: \"Jane Doe\",\n            senderPeerID: nil,\n            mentions: nil,\n            deliveryStatus: .sent\n        )\n    }\n}\n"
  },
  {
    "path": "bitchat/_PreviewHelpers/PreviewKeychainManager.swift",
    "content": "//\n// PreviewKeychainManager.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\nfinal class PreviewKeychainManager: KeychainManagerProtocol {\n    private var storage: [String: Data] = [:]\n    private var serviceStorage: [String: [String: Data]] = [:]\n    init() {}\n\n    func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {\n        storage[key] = keyData\n        return true\n    }\n\n    func getIdentityKey(forKey key: String) -> Data? {\n        storage[key]\n    }\n\n    func deleteIdentityKey(forKey key: String) -> Bool {\n        storage.removeValue(forKey: key)\n        return true\n    }\n\n    func deleteAllKeychainData() -> Bool {\n        storage.removeAll()\n        serviceStorage.removeAll()\n        return true\n    }\n\n    func secureClear(_ data: inout Data) {}\n\n    func secureClear(_ string: inout String) {}\n\n    func verifyIdentityKeyExists() -> Bool {\n        storage[\"identity_noiseStaticKey\"] != nil\n    }\n\n    // BCH-01-009: New methods with proper error classification\n    func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {\n        if let data = storage[key] {\n            return .success(data)\n        }\n        return .itemNotFound\n    }\n\n    func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {\n        storage[key] = keyData\n        return .success\n    }\n\n    // MARK: - Generic Data Storage (consolidated from KeychainHelper)\n\n    func save(key: String, data: Data, service: String, accessible: CFString?) {\n        if serviceStorage[service] == nil {\n            serviceStorage[service] = [:]\n        }\n        serviceStorage[service]?[key] = data\n    }\n\n    func load(key: String, service: String) -> Data? {\n        serviceStorage[service]?[key]\n    }\n\n    func delete(key: String, service: String) {\n        serviceStorage[service]?.removeValue(forKey: key)\n    }\n}\n"
  },
  {
    "path": "bitchat/bitchat-macOS.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.app-sandbox</key>\n\t<true/>\n\t<key>com.apple.security.application-groups</key>\n\t<array>\n\t\t<string>group.chat.bitchat</string>\n\t</array>\n\t<key>com.apple.security.device.bluetooth</key>\n\t<true/>\n\t<key>com.apple.security.device.microphone</key>\n\t<true/>\n\t<key>com.apple.security.personal-information.location</key>\n\t<true/>\n\t<key>com.apple.security.network.client</key>\n\t<true/>\n\t<key>com.apple.security.network.server</key>\n\t<true/>\n\t<key>com.apple.security.files.user-selected.read-only</key>\n\t<true/>\n\t<key>com.apple.security.files.user-selected.read-write</key>\n\t<true/>\n\t<key>com.apple.security.assets.pictures.read-only</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "bitchat/bitchat.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.app-sandbox</key>\n\t<true/>\n\t<key>com.apple.security.application-groups</key>\n\t<array>\n\t\t<string>group.chat.bitchat</string>\n\t</array>\n\t<key>com.apple.security.device.bluetooth</key>\n\t<true/>\n</dict>\n</plist>"
  },
  {
    "path": "bitchat.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 90;\n\tobjects = {\n\n/* Begin PBXBuildFile section */\n\t\t17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };\n\t\t3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };\n\t\t885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };\n\t\tA6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };\n\t\tA6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };\n\t\tA6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };\n\t\tA6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };\n\t\tE0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };\n\t\tE0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };\n/* End PBXBuildFile section */\n\n/* Begin PBXContainerItemProxy section */\n\t\t96415D4F989854F908EAD303 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = AF077EA0474EDEDE2C72716C;\n\t\t\tremoteInfo = bitchat_iOS;\n\t\t};\n\t\tE35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = 57CA17A36A2532A6CFF367BB;\n\t\t\tremoteInfo = bitchatShareExtension;\n\t\t};\n\t\tFF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = 0576A29205865664C0937536;\n\t\t\tremoteInfo = bitchat_macOS;\n\t\t};\n/* End PBXContainerItemProxy section */\n\n/* Begin PBXCopyFilesBuildPhase section */\n\t\tB6C356449BAE4E0F650565D1 /* Embed Foundation Extensions */ = {\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tdstPath = \"\";\n\t\t\tdstSubfolder = PlugIns;\n\t\t\tfiles = (\n\t\t\t\t17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */,\n\t\t\t);\n\t\t\tname = \"Embed Foundation Extensions\";\n\t\t};\n/* End PBXCopyFilesBuildPhase section */\n\n/* Begin PBXFileReference section */\n\t\t03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = \"wrapper.app-extension\"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tA6F183FC2E948783006A9046 /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = \"tor-nolzma.xcframework\"; path = \"localPackages/Tor/Frameworks/tor-nolzma.xcframework\"; sourceTree = \"<group>\"; };\n\t\tC0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tE0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = \"<group>\"; };\n/* End PBXFileReference section */\n\n/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */\n\t\tA6E32D1B2E762EA70032EA8A /* Exceptions for \"bitchat\" folder in \"bitchatShareExtension\" target */ = {\n\t\t\tisa = PBXFileSystemSynchronizedBuildFileExceptionSet;\n\t\t\tmembershipExceptions = (\n\t\t\t\tServices/TransportConfig.swift,\n\t\t\t);\n\t\t\ttarget = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;\n\t\t};\n\t\tA6E32D1C2E762EA70032EA8A /* Exceptions for \"bitchat\" folder in \"bitchat_iOS\" target */ = {\n\t\t\tisa = PBXFileSystemSynchronizedBuildFileExceptionSet;\n\t\t\tmembershipExceptions = (\n\t\t\t\tInfo.plist,\n\t\t\t);\n\t\t\ttarget = AF077EA0474EDEDE2C72716C /* bitchat_iOS */;\n\t\t};\n\t\tA6E32D1D2E762EA70032EA8A /* Exceptions for \"bitchat\" folder in \"bitchat_macOS\" target */ = {\n\t\t\tisa = PBXFileSystemSynchronizedBuildFileExceptionSet;\n\t\t\tmembershipExceptions = (\n\t\t\t\tInfo.plist,\n\t\t\t\tLaunchScreen.storyboard,\n\t\t\t);\n\t\t\ttarget = 0576A29205865664C0937536 /* bitchat_macOS */;\n\t\t};\n\t\tA6E32D232E762EAB0032EA8A /* Exceptions for \"bitchatShareExtension\" folder in \"bitchatShareExtension\" target */ = {\n\t\t\tisa = PBXFileSystemSynchronizedBuildFileExceptionSet;\n\t\t\tmembershipExceptions = (\n\t\t\t\tShareViewController.swift,\n\t\t\t);\n\t\t\ttarget = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;\n\t\t};\n\t\tC5E027A52ECCDFD700BD6012 /* Exceptions for \"bitchatTests\" folder in \"bitchatTests_macOS\" target */ = {\n\t\t\tisa = PBXFileSystemSynchronizedBuildFileExceptionSet;\n\t\t\tmembershipExceptions = (\n\t\t\t\tInfo.plist,\n\t\t\t\tLocalization/PrimaryLocalizationKeys.json,\n\t\t\t\tREADME.md,\n\t\t\t);\n\t\t\ttarget = 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */;\n\t\t};\n\t\tC5E027A82ECCDFE200BD6012 /* Exceptions for \"bitchatTests\" folder in \"bitchatTests_iOS\" target */ = {\n\t\t\tisa = PBXFileSystemSynchronizedBuildFileExceptionSet;\n\t\t\tmembershipExceptions = (\n\t\t\t\tInfo.plist,\n\t\t\t\tLocalization/PrimaryLocalizationKeys.json,\n\t\t\t\tREADME.md,\n\t\t\t);\n\t\t\ttarget = 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */;\n\t\t};\n/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */\n\n/* Begin PBXFileSystemSynchronizedRootGroup section */\n\t\tA6E32C972E762EA70032EA8A /* bitchat */ = {\n\t\t\tisa = PBXFileSystemSynchronizedRootGroup;\n\t\t\texceptions = (\n\t\t\t\tA6E32D1B2E762EA70032EA8A /* Exceptions for \"bitchat\" folder in \"bitchatShareExtension\" target */,\n\t\t\t\tA6E32D1C2E762EA70032EA8A /* Exceptions for \"bitchat\" folder in \"bitchat_iOS\" target */,\n\t\t\t\tA6E32D1D2E762EA70032EA8A /* Exceptions for \"bitchat\" folder in \"bitchat_macOS\" target */,\n\t\t\t);\n\t\t\tpath = bitchat;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tA6E32D212E762EAB0032EA8A /* bitchatShareExtension */ = {\n\t\t\tisa = PBXFileSystemSynchronizedRootGroup;\n\t\t\texceptions = (\n\t\t\t\tA6E32D232E762EAB0032EA8A /* Exceptions for \"bitchatShareExtension\" folder in \"bitchatShareExtension\" target */,\n\t\t\t);\n\t\t\tpath = bitchatShareExtension;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tA6E32D412E762EAE0032EA8A /* bitchatTests */ = {\n\t\t\tisa = PBXFileSystemSynchronizedRootGroup;\n\t\t\texceptions = (\n\t\t\t\tC5E027A82ECCDFE200BD6012 /* Exceptions for \"bitchatTests\" folder in \"bitchatTests_iOS\" target */,\n\t\t\t\tC5E027A52ECCDFD700BD6012 /* Exceptions for \"bitchatTests\" folder in \"bitchatTests_macOS\" target */,\n\t\t\t);\n\t\t\tpath = bitchatTests;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tA6E367C92E76469E0032EA8A /* Configs */ = {\n\t\t\tisa = PBXFileSystemSynchronizedRootGroup;\n\t\t\tpath = Configs;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXFileSystemSynchronizedRootGroup section */\n\n/* Begin PBXFrameworksBuildPhase section */\n\t\t31F6FDADA63050361C14F3A1 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tfiles = (\n\t\t\t\tA6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,\n\t\t\t\t3EE336D150427F736F32B56C /* P256K in Frameworks */,\n\t\t\t\tA6E3EA812E7706A80032EA8A /* Tor in Frameworks */,\n\t\t\t);\n\t\t};\n\t\tB5A5CC493FFB3D8966548140 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tfiles = (\n\t\t\t\tA6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,\n\t\t\t\t885BBED78092484A5B069461 /* P256K in Frameworks */,\n\t\t\t\tA6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,\n\t\t\t);\n\t\t};\n/* End PBXFrameworksBuildPhase section */\n\n/* Begin PBXGroup section */\n\t\t18198ED912AAF495D8AF7763 = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tA6E32C972E762EA70032EA8A /* bitchat */,\n\t\t\t\tE0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */,\n\t\t\t\tA6E32D212E762EAB0032EA8A /* bitchatShareExtension */,\n\t\t\t\tA6E32D412E762EAE0032EA8A /* bitchatTests */,\n\t\t\t\tA6E367C92E76469E0032EA8A /* Configs */,\n\t\t\t\t9F37F9F2C353B58AC809E93B /* Products */,\n\t\t\t\tA6F183FB2E948783006A9046 /* Frameworks */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t9F37F9F2C353B58AC809E93B /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t96D0D41CA19EE5A772AA8434 /* bitchat.app */,\n\t\t\t\t8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */,\n\t\t\t\t61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */,\n\t\t\t\tC0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */,\n\t\t\t\t03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tA6F183FB2E948783006A9046 /* Frameworks */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tA6F183FC2E948783006A9046 /* tor-nolzma.xcframework */,\n\t\t\t);\n\t\t\tname = Frameworks;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXNativeTarget section */\n\t\t0576A29205865664C0937536 /* bitchat_macOS */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = DA5644925338B8189B035657 /* Build configuration list for PBXNativeTarget \"bitchat_macOS\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t137ABE739BF20ACDDF8CC605 /* Sources */,\n\t\t\t\t0214973A876129753D39EB47 /* Resources */,\n\t\t\t\t31F6FDADA63050361C14F3A1 /* Frameworks */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tfileSystemSynchronizedGroups = (\n\t\t\t\tA6E32C972E762EA70032EA8A /* bitchat */,\n\t\t\t);\n\t\t\tname = bitchat_macOS;\n\t\t\tpackageProductDependencies = (\n\t\t\t\tB1D9136AA0083366353BFA2F /* P256K */,\n\t\t\t\tA6E3E5712E7703760032EA8A /* BitLogger */,\n\t\t\t\tA6E3EA802E7706A80032EA8A /* Tor */,\n\t\t\t);\n\t\t\tproductName = bitchat_macOS;\n\t\t\tproductReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t};\n\t\t47FF23248747DD7CB666CB91 /* bitchatTests_macOS */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget \"bitchatTests_macOS\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t5C22AA7B9ACC5A861445C769 /* Sources */,\n\t\t\t\tC5E027A42ECCDFD700BD6012 /* Resources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t\t4AA8605DCAA64A45657EF0CA /* PBXTargetDependency */,\n\t\t\t);\n\t\t\tfileSystemSynchronizedGroups = (\n\t\t\t\tA6E32D412E762EAE0032EA8A /* bitchatTests */,\n\t\t\t);\n\t\t\tname = bitchatTests_macOS;\n\t\t\tproductName = bitchatTests_macOS;\n\t\t\tproductReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */;\n\t\t\tproductType = \"com.apple.product-type.bundle.unit-test\";\n\t\t};\n\t\t57CA17A36A2532A6CFF367BB /* bitchatShareExtension */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget \"bitchatShareExtension\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t0A08E70F08F55FD5BA8C7EF3 /* Sources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tname = bitchatShareExtension;\n\t\t\tproductName = bitchatShareExtension;\n\t\t\tproductReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;\n\t\t\tproductType = \"com.apple.product-type.app-extension\";\n\t\t};\n\t\t6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget \"bitchatTests_iOS\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t865C8403EF02C089369A9FCB /* Sources */,\n\t\t\t\tC5E027A72ECCDFE200BD6012 /* Resources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t\tD8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */,\n\t\t\t);\n\t\t\tfileSystemSynchronizedGroups = (\n\t\t\t\tA6E32D412E762EAE0032EA8A /* bitchatTests */,\n\t\t\t);\n\t\t\tname = bitchatTests_iOS;\n\t\t\tproductName = bitchatTests_iOS;\n\t\t\tproductReference = C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */;\n\t\t\tproductType = \"com.apple.product-type.bundle.unit-test\";\n\t\t};\n\t\tAF077EA0474EDEDE2C72716C /* bitchat_iOS */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 53EADEF7546F94DDF82271B9 /* Build configuration list for PBXNativeTarget \"bitchat_iOS\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t4E49E34F00154C051AE90FED /* Sources */,\n\t\t\t\tCD6E8F32BC38357473954F97 /* Resources */,\n\t\t\t\tB5A5CC493FFB3D8966548140 /* Frameworks */,\n\t\t\t\tB6C356449BAE4E0F650565D1 /* Embed Foundation Extensions */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t\t6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */,\n\t\t\t);\n\t\t\tfileSystemSynchronizedGroups = (\n\t\t\t\tA6E32C972E762EA70032EA8A /* bitchat */,\n\t\t\t);\n\t\t\tname = bitchat_iOS;\n\t\t\tpackageProductDependencies = (\n\t\t\t\t4EB6BA1B8464F1EA38F4E286 /* P256K */,\n\t\t\t\tA6E3E56F2E77036A0032EA8A /* BitLogger */,\n\t\t\t\tA6E3EA7E2E7706720032EA8A /* Tor */,\n\t\t\t);\n\t\t\tproductName = bitchat_iOS;\n\t\t\tproductReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\t475D96681D0EA0AE57A4E06E /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tBuildIndependentTargetsInParallel = YES;\n\t\t\t\tLastUpgradeCheck = 1640;\n\t\t\t};\n\t\t\tbuildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject \"bitchat\" */;\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\tBase,\n\t\t\t\ten,\n\t\t\t\tes,\n\t\t\t\tar,\n\t\t\t\tde,\n\t\t\t\tfr,\n\t\t\t\the,\n\t\t\t\tid,\n\t\t\t\tit,\n\t\t\t\tja,\n\t\t\t\tne,\n\t\t\t\t\"pt-BR\",\n\t\t\t\tru,\n\t\t\t\ttr,\n\t\t\t\tuk,\n\t\t\t\t\"zh-Hans\",\n\t\t\t);\n\t\t\tmainGroup = 18198ED912AAF495D8AF7763;\n\t\t\tminimizedProjectReferenceProxies = 1;\n\t\t\tpackageReferences = (\n\t\t\t\tB8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference \"swift-secp256k1\" */,\n\t\t\t\tA6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference \"localPackages/BitLogger\" */,\n\t\t\t\tA6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference \"localPackages/Arti\" */,\n\t\t\t);\n\t\t\tpreferredProjectObjectVersion = 90;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\t57CA17A36A2532A6CFF367BB /* bitchatShareExtension */,\n\t\t\t\t6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */,\n\t\t\t\t47FF23248747DD7CB666CB91 /* bitchatTests_macOS */,\n\t\t\t\tAF077EA0474EDEDE2C72716C /* bitchat_iOS */,\n\t\t\t\t0576A29205865664C0937536 /* bitchat_macOS */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXResourcesBuildPhase section */\n\t\t0214973A876129753D39EB47 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tfiles = (\n\t\t\t\tE0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,\n\t\t\t);\n\t\t};\n\t\tC5E027A42ECCDFD700BD6012 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tfiles = (\n\t\t\t);\n\t\t};\n\t\tC5E027A72ECCDFE200BD6012 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tfiles = (\n\t\t\t);\n\t\t};\n\t\tCD6E8F32BC38357473954F97 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tfiles = (\n\t\t\t\tE0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,\n\t\t\t);\n\t\t};\n/* End PBXResourcesBuildPhase section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\t0A08E70F08F55FD5BA8C7EF3 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tfiles = (\n\t\t\t);\n\t\t};\n\t\t137ABE739BF20ACDDF8CC605 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tfiles = (\n\t\t\t);\n\t\t};\n\t\t4E49E34F00154C051AE90FED /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tfiles = (\n\t\t\t);\n\t\t};\n\t\t5C22AA7B9ACC5A861445C769 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tfiles = (\n\t\t\t);\n\t\t};\n\t\t865C8403EF02C089369A9FCB /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tfiles = (\n\t\t\t);\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin PBXTargetDependency section */\n\t\t4AA8605DCAA64A45657EF0CA /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = 0576A29205865664C0937536 /* bitchat_macOS */;\n\t\t\ttargetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */;\n\t\t};\n\t\t6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;\n\t\t\ttargetProxy = E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */;\n\t\t};\n\t\tD8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = AF077EA0474EDEDE2C72716C /* bitchat_iOS */;\n\t\t\ttargetProxy = 96415D4F989854F908EAD303 /* PBXContainerItemProxy */;\n\t\t};\n/* End PBXTargetDependency section */\n\n/* Begin XCBuildConfiguration section */\n\t\t077A5203074247CF8F766E2F /* Debug configuration for PBXNativeTarget \"bitchatTests_iOS\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;\n\t\t\tbaseConfigurationReferenceRelativePath = Debug.xcconfig;\n\t\t\tbuildSettings = {\n\t\t\t\tBUNDLE_LOADER = \"$(TEST_HOST)\";\n\t\t\t\tCODE_SIGNING_ALLOWED = YES;\n\t\t\t\tCODE_SIGNING_REQUIRED = YES;\n\t\t\t\tCODE_SIGN_STYLE = \"$(CODE_SIGN_STYLE)\";\n\t\t\t\tDEVELOPMENT_TEAM = \"$(DEVELOPMENT_TEAM)\";\n\t\t\t\tINFOPLIST_FILE = bitchatTests/Info.plist;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = \"$(IPHONEOS_DEPLOYMENT_TARGET)\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@loader_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"$(PRODUCT_BUNDLE_IDENTIFIER).tests\";\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSWIFT_VERSION = \"$(SWIFT_VERSION)\";\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tTEST_HOST = \"$(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t0DACAA261446D178EDD30ECA /* Release configuration for PBXNativeTarget \"bitchatTests_iOS\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;\n\t\t\tbaseConfigurationReferenceRelativePath = Release.xcconfig;\n\t\t\tbuildSettings = {\n\t\t\t\tBUNDLE_LOADER = \"$(TEST_HOST)\";\n\t\t\t\tCODE_SIGNING_ALLOWED = YES;\n\t\t\t\tCODE_SIGNING_REQUIRED = YES;\n\t\t\t\tCODE_SIGN_STYLE = \"$(CODE_SIGN_STYLE)\";\n\t\t\t\tDEVELOPMENT_TEAM = \"$(DEVELOPMENT_TEAM)\";\n\t\t\t\tINFOPLIST_FILE = bitchatTests/Info.plist;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = \"$(IPHONEOS_DEPLOYMENT_TARGET)\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@loader_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"$(PRODUCT_BUNDLE_IDENTIFIER).tests\";\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSWIFT_VERSION = \"$(SWIFT_VERSION)\";\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tTEST_HOST = \"$(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t147FDAE548082D5B921C6F0B /* Release configuration for PBXNativeTarget \"bitchatTests_macOS\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;\n\t\t\tbaseConfigurationReferenceRelativePath = Release.xcconfig;\n\t\t\tbuildSettings = {\n\t\t\t\tBUNDLE_LOADER = \"$(TEST_HOST)\";\n\t\t\t\tCODE_SIGNING_ALLOWED = YES;\n\t\t\t\tCODE_SIGNING_REQUIRED = YES;\n\t\t\t\tCODE_SIGN_STYLE = \"$(CODE_SIGN_STYLE)\";\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tDEVELOPMENT_TEAM = \"$(DEVELOPMENT_TEAM)\";\n\t\t\t\tINFOPLIST_FILE = bitchatTests/Info.plist;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t\t\"@loader_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = \"$(MACOSX_DEPLOYMENT_TARGET)\";\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"$(PRODUCT_BUNDLE_IDENTIFIER).tests\";\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSWIFT_VERSION = \"$(SWIFT_VERSION)\";\n\t\t\t\tTEST_HOST = \"$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget \"bitchatShareExtension\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;\n\t\t\tbaseConfigurationReferenceRelativePath = Release.xcconfig;\n\t\t\tbuildSettings = {\n\t\t\t\tCODE_SIGNING_ALLOWED = YES;\n\t\t\t\tCODE_SIGNING_REQUIRED = YES;\n\t\t\t\tCODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;\n\t\t\t\tCODE_SIGN_STYLE = \"$(CODE_SIGN_STYLE)\";\n\t\t\t\tDEVELOPMENT_TEAM = \"$(DEVELOPMENT_TEAM)\";\n\t\t\t\tINFOPLIST_FILE = bitchatShareExtension/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = bitchat;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = \"$(IPHONEOS_DEPLOYMENT_TARGET)\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@executable_path/../../Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = \"$(MARKETING_VERSION)\";\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension\";\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSUPPORTED_PLATFORMS = \"iphoneos iphonesimulator\";\n\t\t\t\tSUPPORTS_MACCATALYST = NO;\n\t\t\t\tSUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSWIFT_VERSION = \"$(SWIFT_VERSION)\";\n\t\t\t\tTARGETED_DEVICE_FAMILY = 1;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget \"bitchat_iOS\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;\n\t\t\tbaseConfigurationReferenceRelativePath = Debug.xcconfig;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;\n\t\t\t\tASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;\n\t\t\t\tCODE_SIGNING_ALLOWED = YES;\n\t\t\t\tCODE_SIGNING_REQUIRED = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;\n\t\t\t\tCODE_SIGN_STYLE = \"$(CODE_SIGN_STYLE)\";\n\t\t\t\tDEVELOPMENT_TEAM = \"$(DEVELOPMENT_TEAM)\";\n\t\t\t\tENABLE_PREVIEWS = NO;\n\t\t\t\tINFOPLIST_FILE = bitchat/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = bitchat;\n\t\t\t\tINFOPLIST_KEY_LSApplicationCategoryType = \"public.app-category.social-networking\";\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = \"$(IPHONEOS_DEPLOYMENT_TARGET)\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = 1.5.1;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"$(PRODUCT_BUNDLE_IDENTIFIER)\";\n\t\t\t\tPRODUCT_NAME = bitchat;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSUPPORTED_PLATFORMS = \"iphoneos iphonesimulator\";\n\t\t\t\tSUPPORTS_MACCATALYST = NO;\n\t\t\t\tSUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSWIFT_VERSION = \"$(SWIFT_VERSION)\";\n\t\t\t\tTARGETED_DEVICE_FAMILY = 1;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t7FA2BADBF3B325125030CAB1 /* Debug configuration for PBXNativeTarget \"bitchatTests_macOS\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;\n\t\t\tbaseConfigurationReferenceRelativePath = Debug.xcconfig;\n\t\t\tbuildSettings = {\n\t\t\t\tBUNDLE_LOADER = \"$(TEST_HOST)\";\n\t\t\t\tCODE_SIGNING_ALLOWED = YES;\n\t\t\t\tCODE_SIGNING_REQUIRED = YES;\n\t\t\t\tCODE_SIGN_STYLE = \"$(CODE_SIGN_STYLE)\";\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tDEVELOPMENT_TEAM = \"$(DEVELOPMENT_TEAM)\";\n\t\t\t\tINFOPLIST_FILE = bitchatTests/Info.plist;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t\t\"@loader_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = \"$(MACOSX_DEPLOYMENT_TARGET)\";\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"$(PRODUCT_BUNDLE_IDENTIFIER).tests\";\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSWIFT_VERSION = \"$(SWIFT_VERSION)\";\n\t\t\t\tTEST_HOST = \"$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tB36671AEACCBF92BE10852E9 /* Release configuration for PBXNativeTarget \"bitchat_iOS\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;\n\t\t\tbaseConfigurationReferenceRelativePath = Release.xcconfig;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;\n\t\t\t\tCODE_SIGNING_ALLOWED = YES;\n\t\t\t\tCODE_SIGNING_REQUIRED = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;\n\t\t\t\tCODE_SIGN_STYLE = \"$(CODE_SIGN_STYLE)\";\n\t\t\t\tDEVELOPMENT_TEAM = \"$(DEVELOPMENT_TEAM)\";\n\t\t\t\tENABLE_PREVIEWS = YES;\n\t\t\t\tINFOPLIST_FILE = bitchat/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = bitchat;\n\t\t\t\tINFOPLIST_KEY_LSApplicationCategoryType = \"public.app-category.social-networking\";\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = \"$(IPHONEOS_DEPLOYMENT_TARGET)\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = 1.5.1;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"$(PRODUCT_BUNDLE_IDENTIFIER)\";\n\t\t\t\tPRODUCT_NAME = bitchat;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSUPPORTED_PLATFORMS = \"iphoneos iphonesimulator\";\n\t\t\t\tSUPPORTS_MACCATALYST = NO;\n\t\t\t\tSUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSWIFT_VERSION = \"$(SWIFT_VERSION)\";\n\t\t\t\tTARGETED_DEVICE_FAMILY = 1;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tBB044400A0F06B93F22D0D55 /* Release configuration for PBXNativeTarget \"bitchat_macOS\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;\n\t\t\tbaseConfigurationReferenceRelativePath = Release.xcconfig;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;\n\t\t\t\tCODE_SIGNING_ALLOWED = YES;\n\t\t\t\tCODE_SIGNING_REQUIRED = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = \"bitchat/bitchat-macOS.entitlements\";\n\t\t\t\tCODE_SIGN_STYLE = \"$(CODE_SIGN_STYLE)\";\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tDEVELOPMENT_TEAM = \"$(DEVELOPMENT_TEAM)\";\n\t\t\t\tENABLE_PREVIEWS = YES;\n\t\t\t\tINFOPLIST_FILE = bitchat/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = bitchat;\n\t\t\t\tINFOPLIST_KEY_LSApplicationCategoryType = \"public.app-category.social-networking\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = \"$(MACOSX_DEPLOYMENT_TARGET)\";\n\t\t\t\tMARKETING_VERSION = 1.5.1;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"$(PRODUCT_BUNDLE_IDENTIFIER)\";\n\t\t\t\tPRODUCT_NAME = bitchat;\n\t\t\t\tREGISTER_APP_GROUPS = YES;\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSWIFT_VERSION = \"$(SWIFT_VERSION)\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tBF0D85727BCB6E346962F419 /* Release configuration for PBXProject \"bitchat\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++14\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tCURRENT_PROJECT_VERSION = \"$(CURRENT_PROJECT_VERSION)\";\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu11;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = \"$(IPHONEOS_DEPLOYMENT_TARGET)\";\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = \"$(MACOSX_DEPLOYMENT_TARGET)\";\n\t\t\t\tMARKETING_VERSION = \"$(MARKETING_VERSION)\";\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-O\";\n\t\t\t\tSWIFT_VERSION = \"$(SWIFT_VERSION)\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tCC79F65842D42034ACEE79B7 /* Debug configuration for PBXNativeTarget \"bitchat_macOS\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;\n\t\t\tbaseConfigurationReferenceRelativePath = Debug.xcconfig;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;\n\t\t\t\tASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;\n\t\t\t\tCODE_SIGNING_ALLOWED = YES;\n\t\t\t\tCODE_SIGNING_REQUIRED = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = \"bitchat/bitchat-macOS.entitlements\";\n\t\t\t\tCODE_SIGN_STYLE = \"$(CODE_SIGN_STYLE)\";\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tDEVELOPMENT_TEAM = \"$(DEVELOPMENT_TEAM)\";\n\t\t\t\tENABLE_PREVIEWS = NO;\n\t\t\t\tINFOPLIST_FILE = bitchat/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = bitchat;\n\t\t\t\tINFOPLIST_KEY_LSApplicationCategoryType = \"public.app-category.social-networking\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = \"$(MACOSX_DEPLOYMENT_TARGET)\";\n\t\t\t\tMARKETING_VERSION = 1.5.1;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"$(PRODUCT_BUNDLE_IDENTIFIER)\";\n\t\t\t\tPRODUCT_NAME = bitchat;\n\t\t\t\tREGISTER_APP_GROUPS = YES;\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSWIFT_VERSION = \"$(SWIFT_VERSION)\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tD8C5BF109BB2630752185FA0 /* Debug configuration for PBXProject \"bitchat\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++14\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tCURRENT_PROJECT_VERSION = \"$(CURRENT_PROJECT_VERSION)\";\n\t\t\t\tDEAD_CODE_STRIPPING = YES;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_TESTABILITY = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu11;\n\t\t\t\tGCC_DYNAMIC_NO_PIC = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;\n\t\t\t\tGCC_PREPROCESSOR_DEFINITIONS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"DEBUG=1\",\n\t\t\t\t);\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = \"$(IPHONEOS_DEPLOYMENT_TARGET)\";\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = \"$(MACOSX_DEPLOYMENT_TARGET)\";\n\t\t\t\tMARKETING_VERSION = \"$(MARKETING_VERSION)\";\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t\tSWIFT_VERSION = \"$(SWIFT_VERSION)\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tDAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget \"bitchatShareExtension\" */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;\n\t\t\tbaseConfigurationReferenceRelativePath = Debug.xcconfig;\n\t\t\tbuildSettings = {\n\t\t\t\tCODE_SIGNING_ALLOWED = YES;\n\t\t\t\tCODE_SIGNING_REQUIRED = YES;\n\t\t\t\tCODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;\n\t\t\t\tCODE_SIGN_STYLE = \"$(CODE_SIGN_STYLE)\";\n\t\t\t\tDEVELOPMENT_TEAM = \"$(DEVELOPMENT_TEAM)\";\n\t\t\t\tINFOPLIST_FILE = bitchatShareExtension/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = bitchat;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = \"$(IPHONEOS_DEPLOYMENT_TARGET)\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@executable_path/../../Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = \"$(MARKETING_VERSION)\";\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension\";\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSUPPORTED_PLATFORMS = \"iphoneos iphonesimulator\";\n\t\t\t\tSUPPORTS_MACCATALYST = NO;\n\t\t\t\tSUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSWIFT_VERSION = \"$(SWIFT_VERSION)\";\n\t\t\t\tTARGETED_DEVICE_FAMILY = 1;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\t1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget \"bitchatTests_macOS\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t7FA2BADBF3B325125030CAB1 /* Debug configuration for PBXNativeTarget \"bitchatTests_macOS\" */,\n\t\t\t\t147FDAE548082D5B921C6F0B /* Release configuration for PBXNativeTarget \"bitchatTests_macOS\" */,\n\t\t\t);\n\t\t\tdefaultConfigurationName = Debug;\n\t\t};\n\t\t38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget \"bitchatTests_iOS\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t077A5203074247CF8F766E2F /* Debug configuration for PBXNativeTarget \"bitchatTests_iOS\" */,\n\t\t\t\t0DACAA261446D178EDD30ECA /* Release configuration for PBXNativeTarget \"bitchatTests_iOS\" */,\n\t\t\t);\n\t\t\tdefaultConfigurationName = Debug;\n\t\t};\n\t\t3EA424CBD51200895D361189 /* Build configuration list for PBXProject \"bitchat\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tD8C5BF109BB2630752185FA0 /* Debug configuration for PBXProject \"bitchat\" */,\n\t\t\t\tBF0D85727BCB6E346962F419 /* Release configuration for PBXProject \"bitchat\" */,\n\t\t\t);\n\t\t\tdefaultConfigurationName = Debug;\n\t\t};\n\t\t53EADEF7546F94DDF82271B9 /* Build configuration list for PBXNativeTarget \"bitchat_iOS\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget \"bitchat_iOS\" */,\n\t\t\t\tB36671AEACCBF92BE10852E9 /* Release configuration for PBXNativeTarget \"bitchat_iOS\" */,\n\t\t\t);\n\t\t\tdefaultConfigurationName = Debug;\n\t\t};\n\t\tDA5644925338B8189B035657 /* Build configuration list for PBXNativeTarget \"bitchat_macOS\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tCC79F65842D42034ACEE79B7 /* Debug configuration for PBXNativeTarget \"bitchat_macOS\" */,\n\t\t\t\tBB044400A0F06B93F22D0D55 /* Release configuration for PBXNativeTarget \"bitchat_macOS\" */,\n\t\t\t);\n\t\t\tdefaultConfigurationName = Debug;\n\t\t};\n\t\tE4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget \"bitchatShareExtension\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tDAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget \"bitchatShareExtension\" */,\n\t\t\t\t3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget \"bitchatShareExtension\" */,\n\t\t\t);\n\t\t\tdefaultConfigurationName = Debug;\n\t\t};\n/* End XCConfigurationList section */\n\n/* Begin XCLocalSwiftPackageReference section */\n\t\tA6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference \"localPackages/BitLogger\" */ = {\n\t\t\tisa = XCLocalSwiftPackageReference;\n\t\t\trelativePath = localPackages/BitLogger;\n\t\t};\n\t\tA6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference \"localPackages/Arti\" */ = {\n\t\t\tisa = XCLocalSwiftPackageReference;\n\t\t\trelativePath = localPackages/Arti;\n\t\t};\n/* End XCLocalSwiftPackageReference section */\n\n/* Begin XCRemoteSwiftPackageReference section */\n\t\tB8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference \"swift-secp256k1\" */ = {\n\t\t\tisa = XCRemoteSwiftPackageReference;\n\t\t\trepositoryURL = \"https://github.com/21-DOT-DEV/swift-secp256k1\";\n\t\t\trequirement = {\n\t\t\t\tkind = exactVersion;\n\t\t\t\tversion = 0.21.1;\n\t\t\t};\n\t\t};\n/* End XCRemoteSwiftPackageReference section */\n\n/* Begin XCSwiftPackageProductDependency section */\n\t\t4EB6BA1B8464F1EA38F4E286 /* P256K */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference \"swift-secp256k1\" */;\n\t\t\tproductName = P256K;\n\t\t};\n\t\tA6E3E56F2E77036A0032EA8A /* BitLogger */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tproductName = BitLogger;\n\t\t};\n\t\tA6E3E5712E7703760032EA8A /* BitLogger */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tproductName = BitLogger;\n\t\t};\n\t\tA6E3EA7E2E7706720032EA8A /* Tor */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tproductName = Tor;\n\t\t};\n\t\tA6E3EA802E7706A80032EA8A /* Tor */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tproductName = Tor;\n\t\t};\n\t\tB1D9136AA0083366353BFA2F /* P256K */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference \"swift-secp256k1\" */;\n\t\t\tproductName = P256K;\n\t\t};\n/* End XCSwiftPackageProductDependency section */\n\t};\n\trootObject = 475D96681D0EA0AE57A4E06E /* Project object */;\n}\n"
  },
  {
    "path": "bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1640\"\n   version = \"1.3\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"57CA17A36A2532A6CFF367BB\"\n               BuildableName = \"bitchatShareExtension.appex\"\n               BlueprintName = \"bitchatShareExtension\"\n               ReferencedContainer = \"container:bitchat.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"AF077EA0474EDEDE2C72716C\"\n               BuildableName = \"bitchat.app\"\n               BlueprintName = \"bitchat_iOS\"\n               ReferencedContainer = \"container:bitchat.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      codeCoverageEnabled = \"YES\"\n      onlyGenerateCoverageForSpecifiedTargets = \"YES\">\n      <MacroExpansion>\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"AF077EA0474EDEDE2C72716C\"\n            BuildableName = \"bitchat.app\"\n            BlueprintName = \"bitchat_iOS\"\n            ReferencedContainer = \"container:bitchat.xcodeproj\">\n         </BuildableReference>\n      </MacroExpansion>\n      <CodeCoverageTargets>\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"AF077EA0474EDEDE2C72716C\"\n            BuildableName = \"bitchat.app\"\n            BlueprintName = \"bitchat_iOS\"\n            ReferencedContainer = \"container:bitchat.xcodeproj\">\n         </BuildableReference>\n      </CodeCoverageTargets>\n      <Testables>\n         <TestableReference\n            skipped = \"NO\"\n            parallelizable = \"YES\"\n            testExecutionOrdering = \"random\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"6CB97DF2EA57234CB3E563B8\"\n               BuildableName = \"bitchatTests_iOS.xctest\"\n               BlueprintName = \"bitchatTests_iOS\"\n               ReferencedContainer = \"container:bitchat.xcodeproj\">\n            </BuildableReference>\n         </TestableReference>\n      </Testables>\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"AF077EA0474EDEDE2C72716C\"\n            BuildableName = \"bitchat.app\"\n            BlueprintName = \"bitchat_iOS\"\n            ReferencedContainer = \"container:bitchat.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n      <EnvironmentVariables>\n         <EnvironmentVariable\n            key = \"BITCHAT_LOG_LEVEL\"\n            value = \"debug\"\n            isEnabled = \"YES\">\n         </EnvironmentVariable>\n      </EnvironmentVariables>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Release\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"AF077EA0474EDEDE2C72716C\"\n            BuildableName = \"bitchat.app\"\n            BlueprintName = \"bitchat_iOS\"\n            ReferencedContainer = \"container:bitchat.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (macOS).xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1640\"\n   version = \"1.3\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"0576A29205865664C0937536\"\n               BuildableName = \"bitchat.app\"\n               BlueprintName = \"bitchat_macOS\"\n               ReferencedContainer = \"container:bitchat.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\">\n      <MacroExpansion>\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"0576A29205865664C0937536\"\n            BuildableName = \"bitchat.app\"\n            BlueprintName = \"bitchat_macOS\"\n            ReferencedContainer = \"container:bitchat.xcodeproj\">\n         </BuildableReference>\n      </MacroExpansion>\n      <Testables>\n         <TestableReference\n            skipped = \"NO\"\n            parallelizable = \"NO\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"47FF23248747DD7CB666CB91\"\n               BuildableName = \"bitchatTests_macOS.xctest\"\n               BlueprintName = \"bitchatTests_macOS\"\n               ReferencedContainer = \"container:bitchat.xcodeproj\">\n            </BuildableReference>\n         </TestableReference>\n      </Testables>\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"0576A29205865664C0937536\"\n            BuildableName = \"bitchat.app\"\n            BlueprintName = \"bitchat_macOS\"\n            ReferencedContainer = \"container:bitchat.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n      <EnvironmentVariables>\n         <EnvironmentVariable\n            key = \"BITCHAT_LOG_LEVEL\"\n            value = \"debug\"\n            isEnabled = \"YES\">\n         </EnvironmentVariable>\n      </EnvironmentVariables>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Release\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"0576A29205865664C0937536\"\n            BuildableName = \"bitchat.app\"\n            BlueprintName = \"bitchat_macOS\"\n            ReferencedContainer = \"container:bitchat.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "bitchatShareExtension/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>CFBundleDevelopmentRegion</key>\n\t<string>$(DEVELOPMENT_LANGUAGE)</string>\n\t<key>CFBundleDisplayName</key>\n\t<string>bitchat</string>\n\t<key>CFBundleExecutable</key>\n\t<string>$(EXECUTABLE_NAME)</string>\n\t<key>CFBundleIdentifier</key>\n\t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n\t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\t<key>CFBundleName</key>\n\t<string>$(PRODUCT_NAME)</string>\n\t<key>CFBundlePackageType</key>\n\t<string>XPC!</string>\n\t<key>CFBundleShortVersionString</key>\n\t<string>$(MARKETING_VERSION)</string>\n\t<key>CFBundleVersion</key>\n\t<string>$(CURRENT_PROJECT_VERSION)</string>\n\t<key>NSExtension</key>\n\t<dict>\n\t\t<key>NSExtensionAttributes</key>\n\t\t<dict>\n\t\t\t<key>NSExtensionActivationRule</key>\n\t\t\t<dict>\n\t\t\t\t<key>NSExtensionActivationSupportsImageWithMaxCount</key>\n\t\t\t\t<integer>1</integer>\n\t\t\t\t<key>NSExtensionActivationSupportsText</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>\n\t\t\t\t<integer>1</integer>\n\t\t\t</dict>\n\t\t</dict>\n\t\t<key>NSExtensionPointIdentifier</key>\n\t\t<string>com.apple.share-services</string>\n\t\t<key>NSExtensionPrincipalClass</key>\n\t\t<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "bitchatShareExtension/Localization/Localizable.xcstrings",
    "content": "{\n  \"sourceLanguage\": \"en\",\n  \"strings\": {\n    \"share.fallback.shared_link_title\": {\n      \"extractionState\": \"manual\",\n      \"localizations\": {\n        \"en\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"shared Link\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"ar\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"رابط مشترك\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"de\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"geteilter link\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"es\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"enlace compartido\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"fr\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"lien partagé\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"he\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"קישור משותף\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"id\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"tautan dibagikan\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"it\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"link condiviso\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"ja\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"共有リンク\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"ne\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"साझा गरिएको लिङ्क\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"pt-BR\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"link compartilhado\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"ru\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"поделился ссылкой\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"uk\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"спільне посилання\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"zh-Hans\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"分享的链接\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"ko\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"공유된 링크\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        },\n        \"tr\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"paylaşılan bağlantı\",\n            \"comment\": \"Fallback title when saving a shared link\"\n          }\n        }\n      }\n    },\n    \"share.status.failed_to_encode\": {\n      \"extractionState\": \"manual\",\n      \"localizations\": {\n        \"en\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"failed to encode link\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"ar\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"تعذر ترميز الرابط\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"de\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"link konnte nicht codiert werden\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"es\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"no se pudo codificar el enlace\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"fr\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"échec de l'encodage du lien\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"he\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"לא ניתן לקודד את הקישור\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"id\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"gagal mengodekan tautan\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"it\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"impossibile codificare il link\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"ja\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"リンクのエンコードに失敗しました\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"ne\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"लिङ्क सङ्केत गर्न सकेन\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"pt-BR\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"falha ao codificar link\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"ru\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"не удалось закодировать ссылку\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"uk\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"не вдалося закодувати посилання\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"zh-Hans\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"无法编码链接\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"ko\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"링크를 인코딩하는 데 실패했습니다\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        },\n        \"tr\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"bağlantı kodlanamadı\",\n            \"comment\": \"Shown when the share payload cannot be encoded\"\n          }\n        }\n      }\n    },\n    \"share.status.no_shareable_content\": {\n      \"extractionState\": \"manual\",\n      \"localizations\": {\n        \"en\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"no shareable content\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"ar\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"لا محتوى قابلاً للمشاركة\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"de\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"kein teilbarer inhalt\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"es\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"sin contenido que se pueda compartir\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"fr\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"aucun contenu partageable\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"he\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"אין תוכן שניתן לשתף\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"id\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"tidak ada konten yang bisa dibagikan\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"it\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"nessun contenuto condivisibile\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"ja\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"共有可能なコンテンツがありません\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"ne\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"बाँड्न मिल्ने सामग्री छैन\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"pt-BR\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"nenhum conteúdo compartilhável\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"ru\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"нет подходящего контента\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"uk\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"нема відповідного контенту\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"zh-Hans\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"没有可分享的素材\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"ko\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"공유할 수 있는 내용이 없습니다\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        },\n        \"tr\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"paylaşılabilir içerik yok\",\n            \"comment\": \"Shown when provided content cannot be shared\"\n          }\n        }\n      }\n    },\n    \"share.status.nothing_to_share\": {\n      \"extractionState\": \"manual\",\n      \"localizations\": {\n        \"en\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"nothing to share\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"ar\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"لا شيء لمشاركته\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"de\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"nichts zum teilen\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"es\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"nada que compartir\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"fr\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"rien à partager\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"he\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"אין מה לשתף\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"id\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"tidak ada yang bisa dibagikan\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"it\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"niente da condividere\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"ja\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"共有できるものがありません\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"ne\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"बाँड्ने केही छैन\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"pt-BR\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"nada para compartilhar\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"ru\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"нечем поделиться\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"uk\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"нема чим ділитися\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"zh-Hans\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"没有可分享的内容\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"ko\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"공유할 내용이 없습니다\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        },\n        \"tr\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"paylaşılacak bir şey yok\",\n            \"comment\": \"Shown when the share extension receives no content\"\n          }\n        }\n      }\n    },\n    \"share.status.shared_link\": {\n      \"extractionState\": \"manual\",\n      \"localizations\": {\n        \"en\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ shared link to bitchat\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"ar\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ تم إرسال الرابط إلى bitchat\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"de\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ link zu bitchat geteilt\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"es\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ enlace compartido con bitchat\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"fr\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ lien partagé vers bitchat\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"he\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ הקישור נשלח אל bitchat\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"id\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ tautan dikirim ke bitchat\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"it\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ link inviato a bitchat\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"ja\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ bitchatにリンクを共有\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"ne\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ bitchat मा लिङ्क पठाइयो\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"pt-BR\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ link enviado para bitchat\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"ru\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ ссылка отправлена в bitchat\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"uk\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ посилання надіслано в bitchat\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"zh-Hans\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ 已将链接分享至 bitchat\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"ko\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ bitchat으로 링크를 공유했습니다\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        },\n        \"tr\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ bitchat'e bağlantı paylaşıldı\",\n            \"comment\": \"Confirmation after successfully sharing a link\"\n          }\n        }\n      }\n    },\n    \"share.status.shared_text\": {\n      \"extractionState\": \"manual\",\n      \"localizations\": {\n        \"en\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ shared text to bitchat\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"ar\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ تم إرسال النص إلى bitchat\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"de\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ text zu bitchat geteilt\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"es\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ texto compartido con bitchat\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"fr\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ texte partagé vers bitchat\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"he\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ הטקסט נשלח אל bitchat\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"id\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ teks dikirim ke bitchat\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"it\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ testo inviato a bitchat\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"ja\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ bitchatにテキストを共有\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"ne\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ bitchat मा पाठ पठाइयो\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"pt-BR\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ texto enviado para bitchat\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"ru\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ текст отправлен в bitchat\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"uk\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ текст надіслано в bitchat\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"zh-Hans\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ 已将文本分享至 bitchat\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"ko\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ bitchat으로 텍스트를 공유했습니다\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        },\n        \"tr\": {\n          \"stringUnit\": {\n            \"state\": \"translated\",\n            \"value\": \"✓ bitchat'e metin paylaşıldı\",\n            \"comment\": \"Confirmation after successfully sharing text\"\n          }\n        }\n      }\n    }\n  },\n  \"version\": \"1.0\"\n}\n"
  },
  {
    "path": "bitchatShareExtension/ShareViewController.swift",
    "content": "//\n// ShareViewController.swift\n// bitchatShareExtension\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport UIKit\nimport UniformTypeIdentifiers\n\n/// Modern share extension using UIKit + UTTypes.\n/// Avoids deprecated Social framework and SLComposeServiceViewController.\nfinal class ShareViewController: UIViewController {\n    // Bundle.main.bundleIdentifier would get the extension's bundleID\n    private static let groupID = \"group.chat.bitchat\"\n\n    private enum Strings {\n        static let nothingToShare = String(localized: \"share.status.nothing_to_share\", comment: \"Shown when the share extension receives no content\")\n        static let noShareableContent = String(localized: \"share.status.no_shareable_content\", comment: \"Shown when provided content cannot be shared\")\n        static let sharedLinkTitleFallback = String(localized: \"share.fallback.shared_link_title\", comment: \"Fallback title when saving a shared link\")\n        static let sharedLinkConfirmation = String(localized: \"share.status.shared_link\", comment: \"Confirmation after successfully sharing a link\")\n        static let sharedTextConfirmation = String(localized: \"share.status.shared_text\", comment: \"Confirmation after successfully sharing text\")\n        static let failedToEncode = String(localized: \"share.status.failed_to_encode\", comment: \"Shown when the share payload cannot be encoded\")\n    }\n    \n    private let statusLabel: UILabel = {\n        let l = UILabel()\n        l.translatesAutoresizingMaskIntoConstraints = false\n        l.font = .systemFont(ofSize: 15, weight: .semibold)\n        l.textAlignment = .center\n        l.numberOfLines = 0\n        l.textColor = .label\n        return l\n    }()\n\n    override func viewDidLoad() {\n        super.viewDidLoad()\n        view.backgroundColor = .systemBackground\n        view.addSubview(statusLabel)\n        NSLayoutConstraint.activate([\n            statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),\n            statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),\n            statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),\n            statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)\n        ])\n        DispatchQueue.global().async {\n            self.processShare()\n        }\n    }\n\n    // MARK: - Processing\n    private func processShare() {\n        guard let ctx = self.extensionContext,\n              let item = ctx.inputItems.first as? NSExtensionItem else {\n            finishWithMessage(Strings.nothingToShare)\n            return\n        }\n\n        // Try content from attributed text first (Safari often passes URL here)\n        if let url = detectURL(in: item.attributedContentText?.string ?? \"\") {\n            saveAndFinish(url: url, title: item.attributedTitle?.string)\n            return\n        }\n\n        // Scan attachments for URL/text\n        let providers = item.attachments ?? []\n        if providers.isEmpty {\n            // Fallback: use attributed title as plain text\n            if let title = item.attributedTitle?.string, !title.isEmpty {\n                saveAndFinish(text: title)\n            } else {\n                finishWithMessage(Strings.noShareableContent)\n            }\n            return\n        }\n\n        // Load URL or text asynchronously\n        loadFirstURL(from: providers) { [weak self] url in\n            guard let self = self else { return }\n            if let url = url {\n                self.saveAndFinish(url: url, title: item.attributedTitle?.string)\n            } else {\n                self.loadFirstPlainText(from: providers) { text in\n                    if let t = text, !t.isEmpty {\n                        // Treat as URL if parseable http(s), else plain text\n                        if let u = URL(string: t), [\"http\",\"https\"].contains(u.scheme?.lowercased() ?? \"\") {\n                            self.saveAndFinish(url: u, title: item.attributedTitle?.string)\n                        } else {\n                            self.saveAndFinish(text: t)\n                        }\n                    } else {\n                        self.finishWithMessage(Strings.noShareableContent)\n                    }\n                }\n            }\n        }\n    }\n\n    private func detectURL(in text: String) -> URL? {\n        guard !text.isEmpty else { return nil }\n        let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)\n        let range = NSRange(location: 0, length: (text as NSString).length)\n        let match = detector?.matches(in: text, options: [], range: range).first\n        return match?.url\n    }\n\n    private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {\n        let identifiers = [UTType.url.identifier, \"public.url\", \"public.file-url\"]\n        let grp = DispatchGroup()\n        var found: URL?\n\n        for p in providers where found == nil {\n            for id in identifiers where p.hasItemConformingToTypeIdentifier(id) {\n                grp.enter()\n                p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in\n                    defer { grp.leave() }\n                    if let u = item as? URL { found = u; return }\n                    if let s = item as? String, let u = URL(string: s) { found = u; return }\n                    if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return }\n                }\n                break\n            }\n        }\n        grp.notify(queue: .main) { completion(found) }\n    }\n\n    private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) {\n        let id = UTType.plainText.identifier\n        let grp = DispatchGroup()\n        var text: String?\n        for p in providers where p.hasItemConformingToTypeIdentifier(id) {\n            grp.enter()\n            p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in\n                defer { grp.leave() }\n                if let s = item as? String { text = s }\n                else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s }\n            }\n            break\n        }\n        grp.notify(queue: .main) { completion(text) }\n    }\n\n    // MARK: - Save + Finish\n    private func saveAndFinish(url: URL, title: String?) {\n        let payload: [String: String] = [\n            \"url\": url.absoluteString,\n            \"title\": title ?? url.host ?? Strings.sharedLinkTitleFallback\n        ]\n        if let json = try? JSONSerialization.data(withJSONObject: payload),\n           let s = String(data: json, encoding: .utf8) {\n            saveToSharedDefaults(content: s, type: \"url\")\n            finishWithMessage(Strings.sharedLinkConfirmation)\n        } else {\n            finishWithMessage(Strings.failedToEncode)\n        }\n    }\n\n    private func saveAndFinish(text: String) {\n        saveToSharedDefaults(content: text, type: \"text\")\n        finishWithMessage(Strings.sharedTextConfirmation)\n    }\n\n    private func saveToSharedDefaults(content: String, type: String) {\n        guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return }\n        userDefaults.set(content, forKey: \"sharedContent\")\n        userDefaults.set(type, forKey: \"sharedContentType\")\n        userDefaults.set(Date(), forKey: \"sharedContentDate\")\n        // No need to force synchronize; the system persists changes\n    }\n\n    private func finishWithMessage(_ msg: String) {\n        statusLabel.text = msg\n        // Complete shortly after showing status\n        DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {\n            self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)\n        }\n    }\n}\n"
  },
  {
    "path": "bitchatShareExtension/bitchatShareExtension.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.app-sandbox</key>\n\t<true/>\n\t<key>com.apple.security.application-groups</key>\n\t<array>\n\t\t<string>group.chat.bitchat</string>\n\t</array>\n</dict>\n</plist>"
  },
  {
    "path": "bitchatTests/BLEServiceCoreTests.swift",
    "content": "//\n// BLEServiceCoreTests.swift\n// bitchatTests\n//\n// Focused BLEService tests for packet handling behavior.\n//\n\nimport Testing\nimport Foundation\nimport CoreBluetooth\n@testable import bitchat\n\nstruct BLEServiceCoreTests {\n\n    @Test\n    func duplicatePacket_isDeduped() async {\n        let ble = makeService()\n        let delegate = PublicCaptureDelegate()\n        ble.delegate = delegate\n\n        let sender = PeerID(str: \"1122334455667788\")\n        let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)\n        let packet = makePublicPacket(content: \"Hello\", sender: sender, timestamp: timestamp)\n\n        ble._test_handlePacket(packet, fromPeerID: sender)\n        let receivedFirst = await TestHelpers.waitUntil(\n            { delegate.publicMessagesSnapshot().count == 1 },\n            timeout: TestConstants.defaultTimeout\n        )\n        #expect(receivedFirst)\n\n        ble._test_handlePacket(packet, fromPeerID: sender)\n        let receivedDuplicate = await TestHelpers.waitUntil(\n            { delegate.publicMessagesSnapshot().count > 1 },\n            timeout: TestConstants.shortTimeout\n        )\n        #expect(!receivedDuplicate)\n\n        let messages = delegate.publicMessagesSnapshot()\n        #expect(messages.count == 1)\n        #expect(messages.first?.content == \"Hello\")\n    }\n\n    @Test\n    func staleBroadcast_isIgnored() async {\n        let ble = makeService()\n        let delegate = PublicCaptureDelegate()\n        ble.delegate = delegate\n\n        let sender = PeerID(str: \"A1B2C3D4E5F60708\")\n        let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000)\n        let packet = makePublicPacket(content: \"Old\", sender: sender, timestamp: oldTimestamp)\n\n        ble._test_handlePacket(packet, fromPeerID: sender)\n\n        let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3)\n        #expect(!didReceive)\n        #expect(delegate.publicMessagesSnapshot().isEmpty)\n    }\n\n    @Test\n    func announceSenderMismatch_isRejected() async throws {\n        let ble = makeService()\n\n        let signer = NoiseEncryptionService(keychain: MockKeychain())\n        let announcement = AnnouncementPacket(\n            nickname: \"Spoof\",\n            noisePublicKey: signer.getStaticPublicKeyData(),\n            signingPublicKey: signer.getSigningPublicKeyData(),\n            directNeighbors: nil\n        )\n        let payload = try #require(announcement.encode(), \"Failed to encode announcement\")\n\n        let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)\n        let wrongFirst = derivedPeerID.bare.first == \"0\" ? \"1\" : \"0\"\n        let wrongBare = String(wrongFirst) + String(derivedPeerID.bare.dropFirst())\n        let wrongPeerID = PeerID(str: wrongBare)\n        let packet = BitchatPacket(\n            type: MessageType.announce.rawValue,\n            senderID: Data(hexString: wrongPeerID.id) ?? Data(),\n            recipientID: nil,\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: payload,\n            signature: nil,\n            ttl: 7\n        )\n        let signed = try #require(signer.signPacket(packet), \"Failed to sign announce packet\")\n\n        ble._test_handlePacket(signed, fromPeerID: wrongPeerID, preseedPeer: false)\n\n        _ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3)\n        #expect(ble.currentPeerSnapshots().isEmpty)\n    }\n}\n\nprivate func makeService() -> BLEService {\n    let keychain = MockKeychain()\n    let identityManager = MockIdentityManager(keychain)\n    let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())\n    return BLEService(\n        keychain: keychain,\n        idBridge: idBridge,\n        identityManager: identityManager,\n        initializeBluetoothManagers: false\n    )\n}\n\nprivate func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {\n    BitchatPacket(\n        type: MessageType.message.rawValue,\n        senderID: Data(hexString: sender.id) ?? Data(),\n        recipientID: nil,\n        timestamp: timestamp,\n        payload: Data(content.utf8),\n        signature: nil,\n        ttl: 3\n    )\n}\n\nprivate final class PublicCaptureDelegate: BitchatDelegate {\n    private let lock = NSLock()\n    private(set) var publicMessages: [BitchatMessage] = []\n\n    func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {\n        let message = BitchatMessage(\n            id: messageID,\n            sender: nickname,\n            content: content,\n            timestamp: timestamp,\n            isRelay: false,\n            originalSender: nil,\n            isPrivate: false,\n            recipientNickname: nil,\n            senderPeerID: peerID,\n            mentions: nil\n        )\n        lock.lock()\n        publicMessages.append(message)\n        lock.unlock()\n    }\n\n    func didReceiveMessage(_ message: BitchatMessage) {}\n    func didConnectToPeer(_ peerID: PeerID) {}\n    func didDisconnectFromPeer(_ peerID: PeerID) {}\n    func didUpdatePeerList(_ peers: [PeerID]) {}\n    func didUpdateBluetoothState(_ state: CBManagerState) {}\n\n    func publicMessagesSnapshot() -> [BitchatMessage] {\n        lock.lock()\n        defer { lock.unlock() }\n        return publicMessages\n    }\n}\n"
  },
  {
    "path": "bitchatTests/BLEServiceTests.swift",
    "content": "//\n// BLEServiceTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Testing\nimport CoreBluetooth\n@testable import bitchat\n\nstruct BLEServiceTests {\n    private let service: MockBLEService\n    private let myUUID = UUID()\n    private let bus = MockBLEBus()\n    \n    init() {\n        service = MockBLEService.init(bus: bus)\n        service.myPeerID = PeerID(str: myUUID.uuidString)\n        service.mockNickname = \"TestUser\"\n    }\n    \n    // MARK: - Basic Functionality Tests\n    \n    @Test func serviceInitialization() {\n        #expect(service.myPeerID == PeerID(str: myUUID.uuidString))\n        #expect(service.myNickname == \"TestUser\")\n    }\n    \n    @Test func peerConnection() {\n        let somePeerID = PeerID(str: UUID().uuidString)\n        \n        service.simulateConnectedPeer(somePeerID)\n        #expect(service.isPeerConnected(somePeerID))\n        #expect(service.getConnectedPeers().count == 1)\n        \n        service.simulateDisconnectedPeer(somePeerID)\n        #expect(!service.isPeerConnected(somePeerID))\n        #expect(service.getConnectedPeers().count == 0)\n    }\n    \n    @Test func multiplePeerConnections() {\n        let peerID1 = PeerID(str: UUID().uuidString)\n        let peerID2 = PeerID(str: UUID().uuidString)\n        let peerID3 = PeerID(str: UUID().uuidString)\n\n        service.simulateConnectedPeer(peerID1)\n        service.simulateConnectedPeer(peerID2)\n        service.simulateConnectedPeer(peerID3)\n        \n        #expect(service.getConnectedPeers().count == 3)\n        #expect(service.isPeerConnected(peerID1))\n        #expect(service.isPeerConnected(peerID2))\n        #expect(service.isPeerConnected(peerID3))\n        \n        service.simulateDisconnectedPeer(peerID2)\n        #expect(service.getConnectedPeers().count == 2)\n        #expect(!service.isPeerConnected(peerID2))\n    }\n    \n    // MARK: - Message Sending Tests\n    \n    @Test func sendPublicMessage() async throws {\n        try await confirmation { receivedPublicMessage in\n            let delegate = MockBitchatDelegate { message in\n                #expect(message.content == \"Hello, world!\")\n                #expect(message.sender == \"TestUser\")\n                #expect(!message.isPrivate)\n                receivedPublicMessage()\n            }\n            service.delegate = delegate\n            service.sendMessage(\"Hello, world!\")\n            \n            // Allow async processing\n            try await sleep(1.0)\n        }\n        #expect(service.sentMessages.count == 1)\n    }\n    \n    @Test func sendPrivateMessage() async throws {\n        try await confirmation { receivedPrivateMessage in\n            let delegate = MockBitchatDelegate { message in\n                #expect(message.content == \"Secret message\")\n                #expect(message.sender == \"TestUser\")\n                #expect(message.senderPeerID == PeerID(str: myUUID.uuidString))\n                #expect(message.isPrivate)\n                #expect(message.recipientNickname == \"Bob\")\n                receivedPrivateMessage()\n            }\n            service.delegate = delegate\n            service.sendPrivateMessage(\n                \"Secret message\",\n                to: PeerID(str: UUID().uuidString),\n                recipientNickname: \"Bob\",\n                messageID: \"MSG123\"\n            )\n            \n            // Allow async processing\n            try await sleep(1.0)\n        }\n        #expect(service.sentMessages.count == 1)\n    }\n    \n    @Test func sendMessageWithMentions() async throws {\n        try await confirmation { receivedMessageWithMentions in\n            let delegate = MockBitchatDelegate { message in\n                #expect(message.content == \"@alice @bob check this out\")\n                #expect(message.mentions == [\"alice\", \"bob\"])\n                receivedMessageWithMentions()\n            }\n            service.delegate = delegate\n            service.sendMessage(\"@alice @bob check this out\", mentions: [\"alice\", \"bob\"])\n            \n            // Allow async processing\n            try await sleep(1.0)\n        }\n    }\n    \n    // MARK: - Message Reception Tests\n    \n    @Test func simulateIncomingMessage() async throws {\n        try await confirmation { receiveMessage in\n            let peerID = PeerID(str: UUID().uuidString)\n            \n            let delegate = MockBitchatDelegate { message in\n                #expect(message.content == \"Incoming message\")\n                #expect(message.sender == \"RemoteUser\")\n                #expect(message.senderPeerID == peerID)\n                receiveMessage()\n            }\n            service.delegate = delegate\n            \n            let incomingMessage = BitchatMessage(\n                id: \"MSG456\",\n                sender: \"RemoteUser\",\n                content: \"Incoming message\",\n                timestamp: Date(),\n                isRelay: false,\n                originalSender: nil,\n                isPrivate: false,\n                recipientNickname: nil,\n                senderPeerID: peerID,\n                mentions: nil\n            )\n            service.simulateIncomingMessage(incomingMessage)\n            \n            // Allow async processing\n            try await sleep(1.0)\n        }\n    }\n    \n    @Test func simulateIncomingPacket() async throws {\n        try await confirmation { processPacket in\n            let peerID = PeerID(str: UUID().uuidString)\n            \n            let delegate = MockBitchatDelegate { message in\n                #expect(message.content == \"Packet message\")\n                #expect(message.senderPeerID == peerID)\n                processPacket()\n            }\n            service.delegate = delegate\n            \n            let message = BitchatMessage(\n                id: \"MSG789\",\n                sender: \"PacketSender\",\n                content: \"Packet message\",\n                timestamp: Date(),\n                isRelay: false,\n                originalSender: nil,\n                isPrivate: false,\n                recipientNickname: nil,\n                senderPeerID: peerID,\n                mentions: nil\n            )\n            \n            let payload = try #require(message.toBinaryPayload(), \"Failed to create binary payload\")\n            \n            let packet = BitchatPacket(\n                type: 0x01,\n                senderID: peerID.id.data(using: .utf8)!,\n                recipientID: nil,\n                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                payload: payload,\n                signature: nil,\n                ttl: 3\n            )\n            \n            service.simulateIncomingPacket(packet)\n            \n            // Allow async processing\n            try await sleep(1.0)\n        }\n    }\n    \n    // MARK: - Peer Nickname Tests\n    \n    @Test func getPeerNicknames() {\n        let peerID1 = PeerID(str: UUID().uuidString)\n        let peerID2 = PeerID(str: UUID().uuidString)\n\n        service.simulateConnectedPeer(peerID1)\n        service.simulateConnectedPeer(peerID2)\n        \n        let nicknames = service.getPeerNicknames()\n        #expect(nicknames.count == 2)\n        #expect(nicknames[peerID1] == \"MockPeer_\\(peerID1)\")\n        #expect(nicknames[peerID2] == \"MockPeer_\\(peerID2)\")\n    }\n    \n    // MARK: - Service State Tests\n    \n    @Test func startStopServices() {\n        service.startServices()\n        service.stopServices()\n        let somePeerID = PeerID(str: UUID().uuidString)\n        service.simulateConnectedPeer(somePeerID)\n        #expect(service.isPeerConnected(somePeerID))\n    }\n    \n    // MARK: - Message Delivery Handler Tests\n    \n    @Test func messageDeliveryHandler() async throws {\n        try await confirmation { deliveryHandler in\n            service.packetDeliveryHandler = { packet in\n                if let msg = BitchatMessage(packet.payload) {\n                    #expect(msg.content == \"Test delivery\")\n                    deliveryHandler()\n                }\n            }\n            service.sendMessage(\"Test delivery\")\n            \n            // Allow async processing\n            try await sleep(1.0)\n        }\n    }\n    \n    @Test func packetDeliveryHandler() async throws {\n        try await confirmation(\"Packet handler called\") { packetHandler in\n            let peerID = PeerID(str: UUID().uuidString)\n            \n            service.packetDeliveryHandler = { packet in\n                #expect(packet.type == 0x01)\n                #expect(packet.senderID == Data(peerID.id.utf8))\n                packetHandler()\n            }\n            \n            let message = BitchatMessage(\n                id: \"PKT123\",\n                sender: \"TestSender\",\n                content: \"Test packet\",\n                timestamp: Date(),\n                isRelay: false,\n                originalSender: nil,\n                isPrivate: false,\n                recipientNickname: nil,\n                senderPeerID: peerID,\n                mentions: nil\n            )\n            \n            let payload = try #require(message.toBinaryPayload(), \"Failed to create payload\")\n            \n            let packet = BitchatPacket(\n                type: 0x01,\n                senderID: peerID.id.data(using: .utf8)!,\n                recipientID: nil,\n                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                payload: payload,\n                signature: nil,\n                ttl: 3\n            )\n            \n            service.simulateIncomingPacket(packet)\n            \n            // Allow async processing\n            try await sleep(1.0)\n        }\n    }\n}\n\n// MARK: - Mock Delegate Helper\n\nprivate final class MockBitchatDelegate: BitchatDelegate {\n    private let messageHandler: (BitchatMessage) -> Void\n    \n    init(_ handler: @escaping (BitchatMessage) -> Void) {\n        self.messageHandler = handler\n    }\n    \n    func didReceiveMessage(_ message: BitchatMessage) {\n        messageHandler(message)\n    }\n    \n    func didConnectToPeer(_ peerID: PeerID) {}\n    func didDisconnectFromPeer(_ peerID: PeerID) {}\n    func didUpdatePeerList(_ peers: [PeerID]) {}\n    func isFavorite(fingerprint: String) -> Bool { return false }\n    func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}\n    func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}\n    func didUpdateBluetoothState(_ state: CBManagerState) {}\n    func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}\n}\n"
  },
  {
    "path": "bitchatTests/BitchatPeerTests.swift",
    "content": "import Foundation\nimport Testing\n@testable import bitchat\n\n@Suite(\"BitchatPeer Tests\")\nstruct BitchatPeerTests {\n    typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship\n\n    @Test(\"Connection state prioritizes bluetooth, mesh, nostr, then offline\")\n    func connectionStatePriorityIsCorrect() {\n        let peerID = PeerID(str: \"0123456789abcdef\")\n        let noiseKey = Data((0..<32).map(UInt8.init))\n        let mutual = makeRelationship(isFavorite: true, theyFavoritedUs: true)\n\n        let bluetooth = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: \"A\", isConnected: true, isReachable: true)\n        let mesh = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: \"A\", isConnected: false, isReachable: true)\n        var nostr = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: \"A\", isConnected: false, isReachable: false)\n        nostr.favoriteStatus = mutual\n        let offline = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: \"A\", isConnected: false, isReachable: false)\n\n        #expect(bluetooth.connectionState == .bluetoothConnected)\n        #expect(mesh.connectionState == .meshReachable)\n        #expect(nostr.connectionState == .nostrAvailable)\n        #expect(offline.connectionState == .offline)\n    }\n\n    @Test(\"Display name falls back to peer prefix and offline icon reflects inbound favorite\")\n    func displayNameAndOfflineIconUseDerivedState() {\n        let peerID = PeerID(str: \"fedcba9876543210\")\n        let noiseKey = Data((32..<64).map(UInt8.init))\n        var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: \"\", isConnected: false, isReachable: false)\n        peer.favoriteStatus = makeRelationship(isFavorite: false, theyFavoritedUs: true)\n\n        #expect(peer.displayName == String(peerID.id.prefix(8)))\n        #expect(peer.statusIcon == \"🌙\")\n    }\n\n    @Test(\"Mutual offline peers show Nostr icon\")\n    func mutualFavoriteOfflinePeerShowsNostrIcon() {\n        let peerID = PeerID(str: \"0011223344556677\")\n        let noiseKey = Data((64..<96).map(UInt8.init))\n        var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: \"Peer\", isConnected: false, isReachable: false)\n        peer.favoriteStatus = makeRelationship(isFavorite: true, theyFavoritedUs: true)\n\n        #expect(peer.statusIcon == \"🌐\")\n        #expect(peer.isFavorite)\n        #expect(peer.isMutualFavorite)\n        #expect(peer.theyFavoritedUs)\n    }\n\n    @Test(\"Equality is based only on peer ID\")\n    func equalityUsesPeerIDOnly() {\n        let peerID = PeerID(str: \"8899aabbccddeeff\")\n        let first = BitchatPeer(\n            peerID: peerID,\n            noisePublicKey: Data(repeating: 1, count: 32),\n            nickname: \"First\",\n            isConnected: false,\n            isReachable: false\n        )\n        let second = BitchatPeer(\n            peerID: peerID,\n            noisePublicKey: Data(repeating: 2, count: 32),\n            nickname: \"Second\",\n            isConnected: true,\n            isReachable: true\n        )\n\n        #expect(first == second)\n    }\n\n    private func makeRelationship(isFavorite: Bool, theyFavoritedUs: Bool) -> FavoriteRelationship {\n        FavoriteRelationship(\n            peerNoisePublicKey: Data(repeating: 7, count: 32),\n            peerNostrPublicKey: \"npub1example\",\n            peerNickname: \"Peer\",\n            isFavorite: isFavorite,\n            theyFavoritedUs: theyFavoritedUs,\n            favoritedAt: Date(timeIntervalSince1970: 1),\n            lastUpdated: Date(timeIntervalSince1970: 2)\n        )\n    }\n}\n"
  },
  {
    "path": "bitchatTests/ChatViewModelDeliveryStatusTests.swift",
    "content": "//\n// ChatViewModelDeliveryStatusTests.swift\n// bitchatTests\n//\n// Tests for ChatViewModel delivery status state machine.\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\n// MARK: - Test Helpers\n\n@MainActor\nprivate func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {\n    let keychain = MockKeychain()\n    let keychainHelper = MockKeychainHelper()\n    let idBridge = NostrIdentityBridge(keychain: keychainHelper)\n    let identityManager = MockIdentityManager(keychain)\n    let transport = MockTransport()\n\n    let viewModel = ChatViewModel(\n        keychain: keychain,\n        idBridge: idBridge,\n        identityManager: identityManager,\n        transport: transport\n    )\n\n    return (viewModel, transport)\n}\n\n// MARK: - Delivery Status Tests\n\nstruct ChatViewModelDeliveryStatusTests {\n\n    // MARK: - Status Transition Tests\n\n    @Test @MainActor\n    func deliveryStatus_noDowngrade_readToDelivered() async {\n        let (viewModel, transport) = makeTestableViewModel()\n        let peerID = PeerID(str: \"0102030405060708\")\n        let messageID = \"test-msg-1\"\n\n        // Setup: create a message with .read status\n        let message = BitchatMessage(\n            id: messageID,\n            sender: viewModel.nickname,\n            content: \"Test message\",\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: true,\n            recipientNickname: \"Peer\",\n            senderPeerID: transport.myPeerID,\n            deliveryStatus: .read(by: \"Peer\", at: Date())\n        )\n        viewModel.privateChats[peerID] = [message]\n\n        // Action: try to downgrade to .delivered\n        viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: \"Peer\", at: Date()))\n\n        // Assert: status should remain .read (no downgrade)\n        let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus\n        #expect({\n            if case .read = currentStatus { return true }\n            return false\n        }())\n    }\n\n    @Test @MainActor\n    func deliveryStatus_upgrade_sentToDelivered() async {\n        let (viewModel, transport) = makeTestableViewModel()\n        let peerID = PeerID(str: \"0102030405060708\")\n        let messageID = \"test-msg-2\"\n\n        // Setup: create a message with .sent status\n        let message = BitchatMessage(\n            id: messageID,\n            sender: viewModel.nickname,\n            content: \"Test message\",\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: true,\n            recipientNickname: \"Peer\",\n            senderPeerID: transport.myPeerID,\n            deliveryStatus: .sent\n        )\n        viewModel.privateChats[peerID] = [message]\n\n        // Action: upgrade to .delivered\n        viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: \"Peer\", at: Date()))\n\n        // Assert: status should be .delivered\n        let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus\n        #expect({\n            if case .delivered = currentStatus { return true }\n            return false\n        }())\n    }\n\n    @Test @MainActor\n    func deliveryStatus_upgrade_deliveredToRead() async {\n        let (viewModel, transport) = makeTestableViewModel()\n        let peerID = PeerID(str: \"0102030405060708\")\n        let messageID = \"test-msg-3\"\n\n        // Setup: create a message with .delivered status\n        let message = BitchatMessage(\n            id: messageID,\n            sender: viewModel.nickname,\n            content: \"Test message\",\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: true,\n            recipientNickname: \"Peer\",\n            senderPeerID: transport.myPeerID,\n            deliveryStatus: .delivered(to: \"Peer\", at: Date().addingTimeInterval(-60))\n        )\n        viewModel.privateChats[peerID] = [message]\n\n        // Action: upgrade to .read\n        viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: \"Peer\", at: Date()))\n\n        // Assert: status should be .read\n        let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus\n        #expect({\n            if case .read = currentStatus { return true }\n            return false\n        }())\n    }\n\n    // MARK: - Read Receipt Handling\n\n    @Test @MainActor\n    func didReceiveReadReceipt_updatesMessageStatus() async {\n        let (viewModel, transport) = makeTestableViewModel()\n        let peerID = PeerID(str: \"0102030405060708\")\n        let messageID = \"test-msg-4\"\n\n        // Setup: create a message with .sent status\n        let message = BitchatMessage(\n            id: messageID,\n            sender: viewModel.nickname,\n            content: \"Test message\",\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: true,\n            recipientNickname: \"Peer\",\n            senderPeerID: transport.myPeerID,\n            deliveryStatus: .sent\n        )\n        viewModel.privateChats[peerID] = [message]\n\n        // Action: receive read receipt\n        let receipt = ReadReceipt(\n            originalMessageID: messageID,\n            readerID: peerID,\n            readerNickname: \"Peer\"\n        )\n        viewModel.didReceiveReadReceipt(receipt)\n\n        // Assert: status should be .read\n        let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus\n        #expect({\n            if case .read = currentStatus { return true }\n            return false\n        }())\n    }\n\n    // MARK: - Public Timeline Status Tests\n\n    @Test @MainActor\n    func deliveryStatus_publicTimeline_updatesCorrectly() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let messageID = \"public-msg-1\"\n\n        // Setup: add a message to public timeline with .sending status\n        let message = BitchatMessage(\n            id: messageID,\n            sender: viewModel.nickname,\n            content: \"Public message\",\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: false,\n            deliveryStatus: .sending\n        )\n        viewModel.messages.append(message)\n\n        // Action: update to .sent\n        viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)\n\n        // Assert\n        let updatedMessage = viewModel.messages.first(where: { $0.id == messageID })\n        #expect({\n            if case .sent = updatedMessage?.deliveryStatus { return true }\n            return false\n        }())\n    }\n\n    // MARK: - Status Rank Tests (for deduplication)\n\n    @Test @MainActor\n    func statusRank_orderingIsCorrect() async {\n        // This tests the implicit ordering used in refreshVisibleMessages\n        // failed < sending < sent < partiallyDelivered < delivered < read\n\n        let statuses: [DeliveryStatus] = [\n            .failed(reason: \"test\"),\n            .sending,\n            .sent,\n            .partiallyDelivered(reached: 1, total: 3),\n            .delivered(to: \"B\", at: Date()),\n            .read(by: \"C\", at: Date())\n        ]\n\n        // Verify each status has a logical progression\n        // This is more of a documentation test to ensure the ranking logic is understood\n        for (index, status) in statuses.enumerated() {\n            switch status {\n            case .failed: #expect(index == 0)\n            case .sending: #expect(index == 1)\n            case .sent: #expect(index == 2)\n            case .partiallyDelivered: #expect(index == 3)\n            case .delivered: #expect(index == 4)\n            case .read: #expect(index == 5)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bitchatTests/ChatViewModelExtensionsTests.swift",
    "content": "//\n// ChatViewModelExtensionsTests.swift\n// bitchatTests\n//\n// Tests for ChatViewModel extensions (PrivateChat, Nostr, Tor).\n//\n\nimport Testing\nimport Foundation\nimport Combine\n#if os(iOS)\nimport UIKit\n#else\nimport AppKit\n#endif\n@testable import bitchat\n\n// MARK: - Test Helpers\n\n@MainActor\nprivate func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {\n    let keychain = MockKeychain()\n    let keychainHelper = MockKeychainHelper()\n    let idBridge = NostrIdentityBridge(keychain: keychainHelper)\n    let identityManager = MockIdentityManager(keychain)\n    let transport = MockTransport()\n\n    let viewModel = ChatViewModel(\n        keychain: keychain,\n        idBridge: idBridge,\n        identityManager: identityManager,\n        transport: transport\n    )\n\n    return (viewModel, transport)\n}\n\n// MARK: - Private Chat Extension Tests\n\nstruct ChatViewModelPrivateChatExtensionTests {\n\n    @Test @MainActor\n    func sendPrivateMessage_mesh_storesAndSends() async {\n        let (viewModel, transport) = makeTestableViewModel()\n        // Use valid hex string for PeerID (32 bytes = 64 hex chars for Noise key usually, or just valid hex)\n        let validHex = \"0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10\"\n        let peerID = PeerID(str: validHex)\n        \n        // Simulate connection\n        transport.connectedPeers.insert(peerID)\n        transport.peerNicknames[peerID] = \"MeshUser\"\n        \n        viewModel.sendPrivateMessage(\"Hello Mesh\", to: peerID)\n        \n        // Verify transport was called\n        // Note: MockTransport stores sent messages\n        // Since sendPrivateMessage delegates to MessageRouter which delegates to Transport...\n        // We need to ensure MessageRouter is using our MockTransport.\n        // ChatViewModel init sets up MessageRouter with the passed transport.\n        \n        // Wait for async processing\n        try? await Task.sleep(nanoseconds: 100_000_000)\n        \n        // Verify message stored locally\n        #expect(viewModel.privateChats[peerID]?.count == 1)\n        #expect(viewModel.privateChats[peerID]?.first?.content == \"Hello Mesh\")\n        \n        // Verify message sent to transport (MockTransport captures sendPrivateMessage)\n        // MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers\n        // Check MockTransport implementation... it might need update or verification\n    }\n\n    @Test @MainActor\n    func sendPrivateMessage_unreachable_setsFailedStatus() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let validHex = \"0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10\"\n        let peerID = PeerID(str: validHex)\n\n        viewModel.sendPrivateMessage(\"Hello\", to: peerID)\n\n        #expect(viewModel.privateChats[peerID]?.count == 1)\n        let status = viewModel.privateChats[peerID]?.last?.deliveryStatus\n        #expect({\n            if case .failed = status { return true }\n            return false\n        }())\n    }\n    \n    @Test @MainActor\n    func handlePrivateMessage_storesMessage() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let peerID = PeerID(str: \"SENDER_001\")\n        \n        let message = BitchatMessage(\n            id: \"msg-1\",\n            sender: \"Sender\",\n            content: \"Private Content\",\n            timestamp: Date(),\n            isRelay: false,\n            originalSender: nil,\n            isPrivate: true,\n            recipientNickname: \"Me\",\n            senderPeerID: peerID\n        )\n        \n        // Simulate receiving a private message via the handlePrivateMessage extension method\n        viewModel.handlePrivateMessage(message)\n        \n        // Verify stored\n        #expect(viewModel.privateChats[peerID]?.count == 1)\n        #expect(viewModel.privateChats[peerID]?.first?.content == \"Private Content\")\n        \n        // Verify notification trigger (unread count should increase if not viewing)\n        #expect(viewModel.unreadPrivateMessages.contains(peerID))\n    }\n    \n    @Test @MainActor\n    func handlePrivateMessage_deduplicates() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let peerID = PeerID(str: \"SENDER_001\")\n        \n        let message = BitchatMessage(\n            id: \"msg-1\",\n            sender: \"Sender\",\n            content: \"Content\",\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: true,\n            senderPeerID: peerID\n        )\n        \n        viewModel.handlePrivateMessage(message)\n        viewModel.handlePrivateMessage(message) // Duplicate\n        \n        #expect(viewModel.privateChats[peerID]?.count == 1)\n    }\n    \n    @Test @MainActor\n    func handlePrivateMessage_sendsReadReceipt_whenViewing() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let peerID = PeerID(str: \"SENDER_001\")\n        \n        // Set as currently viewing\n        viewModel.selectedPrivateChatPeer = peerID\n        \n        let message = BitchatMessage(\n            id: \"msg-1\",\n            sender: \"Sender\",\n            content: \"Content\",\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: true,\n            senderPeerID: peerID\n        )\n        \n        viewModel.handlePrivateMessage(message)\n        \n        // Should NOT be marked unread\n        #expect(!viewModel.unreadPrivateMessages.contains(peerID))\n    }\n    \n    @Test @MainActor\n    func migratePrivateChats_consolidatesHistory_onFingerprintMatch() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let oldPeerID = PeerID(str: \"OLD_PEER\")\n        let newPeerID = PeerID(str: \"NEW_PEER\")\n        let fingerprint = \"fp_123\"\n        \n        // Setup old chat\n        let oldMessage = BitchatMessage(\n            id: \"msg-old\",\n            sender: \"User\",\n            content: \"Old message\",\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: true,\n            senderPeerID: oldPeerID\n        )\n        viewModel.privateChats[oldPeerID] = [oldMessage]\n        viewModel.peerIDToPublicKeyFingerprint[oldPeerID] = fingerprint\n        \n        // Setup new peer fingerprint\n        viewModel.peerIDToPublicKeyFingerprint[newPeerID] = fingerprint\n        \n        // Trigger migration\n        viewModel.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: \"User\")\n        \n        // Verify migration\n        #expect(viewModel.privateChats[newPeerID]?.count == 1)\n        #expect(viewModel.privateChats[newPeerID]?.first?.content == \"Old message\")\n        #expect(viewModel.privateChats[oldPeerID] == nil) // Old chat removed\n    }\n    \n    @Test @MainActor\n    func isMessageBlocked_filtersBlockedUsers() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let blockedPeerID = PeerID(str: \"BLOCKED_PEER\")\n        \n        // Block the peer\n        // MockIdentityManager stores state based on fingerprint\n        // We need to map peerID to a fingerprint\n        viewModel.peerIDToPublicKeyFingerprint[blockedPeerID] = \"fp_blocked\"\n        viewModel.identityManager.setBlocked(\"fp_blocked\", isBlocked: true)\n        \n        // Also ensure UnifiedPeerService can resolve the fingerprint.\n        // UnifiedPeerService uses its own cache or delegates to meshService/Peer list.\n        // Since we are mocking, we can't easily inject into UnifiedPeerService's internal cache.\n        // However, ChatViewModel's isMessageBlocked uses:\n        // 1. isPeerBlocked(peerID) -> unifiedPeerService.isBlocked(peerID) -> getFingerprint -> identityManager.isBlocked\n        \n        // We need UnifiedPeerService.getFingerprint(for: blockedPeerID) to return \"fp_blocked\"\n        // UnifiedPeerService tries: cache -> meshService -> getPeer\n        \n        // Option 1: Mock the transport (meshService) to return the fingerprint\n        // (viewModel.transport is MockTransport, but UnifiedPeerService holds a reference to it)\n        // Check if MockTransport has `getFingerprint`\n        \n        // If not, we might need to rely on the fallback: ChatViewModel.isMessageBlocked also checks Nostr blocks.\n        \n        // Let's assume MockTransport needs `getFingerprint` implementation or update it.\n        // For now, let's try to verify if `MockTransport` supports `getFingerprint`.\n        \n        // Actually, let's just use the Nostr block path which is simpler and also tested here.\n        // \"Check geohash (Nostr) blocks using mapping to full pubkey\"\n        \n        let hexPubkey = \"0000000000000000000000000000000000000000000000000000000000000001\"\n        viewModel.nostrKeyMapping[blockedPeerID] = hexPubkey\n        viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true)\n        \n        // Force isGeoChat/isGeoDM check to be true by setting prefix?\n        // Or ensure the logic covers it.\n        // The logic is:\n        // if peerID.isGeoChat || peerID.isGeoDM { check nostr }\n        // We need a peerID that looks like geo.\n        \n        let geoPeerID = PeerID(nostr_: hexPubkey)\n        viewModel.nostrKeyMapping[geoPeerID] = hexPubkey\n        \n        let geoMessage = BitchatMessage(\n            id: \"msg-geo-blocked\",\n            sender: \"BlockedGeoUser\",\n            content: \"Spam\",\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: true,\n            senderPeerID: geoPeerID\n        )\n        \n        #expect(viewModel.isMessageBlocked(geoMessage))\n    }\n}\n\n// MARK: - Nostr Extension Tests\n\nstruct ChatViewModelNostrExtensionTests {\n    \n    @Test @MainActor\n    func switchLocationChannel_mesh_clearsGeo() async {\n        let (viewModel, _) = makeTestableViewModel()\n        \n        // Setup some geo state\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: \"u4pruydq\")))\n        #expect(viewModel.currentGeohash == \"u4pruydq\")\n        \n        // Switch to mesh\n        viewModel.switchLocationChannel(to: .mesh)\n        \n        #expect(viewModel.activeChannel == .mesh)\n        #expect(viewModel.currentGeohash == nil)\n    }\n    \n    @Test @MainActor\n    func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async throws {\n        let geohash = \"u4pruydq\"\n        let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))\n\n        LocationChannelManager.shared.select(channel)\n        defer { LocationChannelManager.shared.select(.mesh) }\n\n        _ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })\n\n        let (viewModel, _) = makeTestableViewModel()\n        \n        _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })\n        \n        let signer = try NostrIdentity.generate()\n        let event = NostrEvent(\n            pubkey: signer.publicKeyHex,\n            createdAt: Date(),\n            kind: .ephemeralEvent,\n            tags: [[\"g\", geohash]],\n            content: \"Hello Geo\"\n        )\n        let signed = try event.sign(with: signer.schnorrSigningKey())\n        viewModel.handleNostrEvent(signed)\n        \n        let didAppend = await TestHelpers.waitUntil({\n            viewModel.publicMessagePipeline.flushIfNeeded()\n            return viewModel.messages.contains { $0.content == \"Hello Geo\" }\n        })\n        #expect(didAppend)\n    }\n\n    @Test @MainActor\n    func handleNostrEvent_ignoresRecentSelfEcho() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n        let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)\n\n        let event = NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .ephemeralEvent,\n            tags: [[\"g\", geohash]],\n            content: \"Self echo\"\n        )\n        let signed = try event.sign(with: identity.schnorrSigningKey())\n        viewModel.handleNostrEvent(signed)\n\n        try? await Task.sleep(nanoseconds: 100_000_000)\n        viewModel.publicMessagePipeline.flushIfNeeded()\n\n        #expect(!viewModel.messages.contains { $0.content == \"Self echo\" })\n    }\n\n    @Test @MainActor\n    func handleNostrEvent_skipsBlockedSender() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n        let blockedIdentity = try NostrIdentity.generate()\n        let blockedPubkey = blockedIdentity.publicKeyHex\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n        viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)\n\n        let event = NostrEvent(\n            pubkey: blockedPubkey,\n            createdAt: Date(),\n            kind: .ephemeralEvent,\n            tags: [[\"g\", geohash]],\n            content: \"Blocked\"\n        )\n        let signed = try event.sign(with: blockedIdentity.schnorrSigningKey())\n        viewModel.handleNostrEvent(signed)\n\n        try? await Task.sleep(nanoseconds: 100_000_000)\n        viewModel.publicMessagePipeline.flushIfNeeded()\n\n        #expect(!viewModel.messages.contains { $0.content == \"Blocked\" })\n    }\n\n    @Test @MainActor\n    func handleNostrEvent_rejectsInvalidSignature() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n        let identity = try NostrIdentity.generate()\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n\n        let event = NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .ephemeralEvent,\n            tags: [[\"g\", geohash]],\n            content: \"Valid\"\n        )\n        var signed = try event.sign(with: identity.schnorrSigningKey())\n        signed.id = \"deadbeef\"\n\n        viewModel.handleNostrEvent(signed)\n\n        try? await Task.sleep(nanoseconds: 100_000_000)\n        viewModel.publicMessagePipeline.flushIfNeeded()\n\n        #expect(!viewModel.messages.contains { $0.content == \"Tampered\" })\n    }\n\n    @Test @MainActor\n    func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n\n        let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)\n        let content = \"bitchat1:\" + base64URLEncode(oversized)\n        let giftWrap = try NostrProtocol.createPrivateMessage(\n            content: content,\n            recipientPubkey: recipient.publicKeyHex,\n            senderIdentity: sender\n        )\n\n        viewModel.subscribeGiftWrap(giftWrap, id: recipient)\n\n        try? await Task.sleep(nanoseconds: 100_000_000)\n        #expect(viewModel.privateChats.isEmpty)\n    }\n\n    @Test @MainActor\n    func switchLocationChannel_clearsNostrDedupCache() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n\n        viewModel.deduplicationService.recordNostrEvent(\"evt-cache\")\n        #expect(viewModel.deduplicationService.hasProcessedNostrEvent(\"evt-cache\"))\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n\n        #expect(!viewModel.deduplicationService.hasProcessedNostrEvent(\"evt-cache\"))\n    }\n\n    @Test @MainActor\n    func handleNostrEvent_presenceTracksParticipantWithoutTimelineMessage() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n        let identity = try NostrIdentity.generate()\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n\n        let event = NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .geohashPresence,\n            tags: [[\"g\", geohash]],\n            content: \"\"\n        )\n        let signed = try event.sign(with: identity.schnorrSigningKey())\n\n        viewModel.handleNostrEvent(signed)\n\n        try? await Task.sleep(nanoseconds: 50_000_000)\n        #expect(viewModel.geohashParticipantCount(for: geohash) >= 1)\n        viewModel.publicMessagePipeline.flushIfNeeded()\n        #expect(viewModel.messages.isEmpty)\n    }\n\n    @Test @MainActor\n    func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        let convKey = PeerID(nostr_: sender.publicKeyHex)\n        let messageID = \"geo-ack-delivered\"\n\n        viewModel.privateChats[convKey] = [\n            BitchatMessage(\n                id: messageID,\n                sender: viewModel.nickname,\n                content: \"Hello\",\n                timestamp: Date(),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Friend\",\n                senderPeerID: viewModel.meshService.myPeerID,\n                deliveryStatus: .sent\n            )\n        ]\n\n        let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: \"0123456789abcdef\"))\n        let giftWrap = try NostrProtocol.createPrivateMessage(\n            content: content,\n            recipientPubkey: recipient.publicKeyHex,\n            senderIdentity: sender\n        )\n\n        viewModel.subscribeGiftWrap(giftWrap, id: recipient)\n\n        let didUpdate = await TestHelpers.waitUntil(\n            { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },\n            timeout: 0.5\n        )\n        #expect(didUpdate)\n    }\n\n    @Test @MainActor\n    func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        let convKey = PeerID(nostr_: sender.publicKeyHex)\n        let messageID = \"geo-ack-read\"\n\n        viewModel.privateChats[convKey] = [\n            BitchatMessage(\n                id: messageID,\n                sender: viewModel.nickname,\n                content: \"Hello\",\n                timestamp: Date(),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Friend\",\n                senderPeerID: viewModel.meshService.myPeerID,\n                deliveryStatus: .delivered(to: \"Friend\", at: Date())\n            )\n        ]\n\n        let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: \"0123456789abcdef\"))\n        let giftWrap = try NostrProtocol.createPrivateMessage(\n            content: content,\n            recipientPubkey: recipient.publicKeyHex,\n            senderIdentity: sender\n        )\n\n        viewModel.subscribeGiftWrap(giftWrap, id: recipient)\n\n        let didUpdate = await TestHelpers.waitUntil(\n            { isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },\n            timeout: 0.5\n        )\n        #expect(didUpdate)\n    }\n\n    @Test @MainActor\n    func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        let messageID = \"gift-private\"\n        let convKey = PeerID(nostr_: sender.publicKeyHex)\n\n        let content = try privateMessageContent(\n            text: \"Hello from gift wrap\",\n            messageID: messageID,\n            senderPeerID: PeerID(str: \"0123456789abcdef\")\n        )\n        let giftWrap = try NostrProtocol.createPrivateMessage(\n            content: content,\n            recipientPubkey: recipient.publicKeyHex,\n            senderIdentity: sender\n        )\n\n        viewModel.handleGiftWrap(giftWrap, id: recipient)\n\n        let didStore = await TestHelpers.waitUntil(\n            { viewModel.privateChats[convKey]?.first?.content == \"Hello from gift wrap\" },\n            timeout: 0.5\n        )\n        #expect(didStore)\n        #expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex)\n        #expect(viewModel.sentGeoDeliveryAcks.contains(messageID))\n    }\n\n    @Test @MainActor\n    func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        let messageID = \"gift-blocked\"\n        let convKey = PeerID(nostr_: sender.publicKeyHex)\n\n        viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)\n\n        let content = try privateMessageContent(\n            text: \"Blocked\",\n            messageID: messageID,\n            senderPeerID: PeerID(str: \"0123456789abcdef\")\n        )\n        let giftWrap = try NostrProtocol.createPrivateMessage(\n            content: content,\n            recipientPubkey: recipient.publicKeyHex,\n            senderIdentity: sender\n        )\n\n        viewModel.handleGiftWrap(giftWrap, id: recipient)\n\n        try? await Task.sleep(nanoseconds: 50_000_000)\n        #expect(viewModel.privateChats[convKey] == nil)\n        #expect(viewModel.sentGeoDeliveryAcks.contains(messageID))\n    }\n\n    @Test @MainActor\n    func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        let convKey = PeerID(nostr_: sender.publicKeyHex)\n        let messageID = \"gift-delivered\"\n\n        viewModel.privateChats[convKey] = [\n            BitchatMessage(\n                id: messageID,\n                sender: viewModel.nickname,\n                content: \"Hello\",\n                timestamp: Date(),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Friend\",\n                senderPeerID: viewModel.meshService.myPeerID,\n                deliveryStatus: .sent\n            )\n        ]\n\n        let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: \"0123456789abcdef\"))\n        let giftWrap = try NostrProtocol.createPrivateMessage(\n            content: content,\n            recipientPubkey: recipient.publicKeyHex,\n            senderIdentity: sender\n        )\n\n        viewModel.handleGiftWrap(giftWrap, id: recipient)\n\n        let didUpdate = await TestHelpers.waitUntil(\n            { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },\n            timeout: 0.5\n        )\n        #expect(didUpdate)\n    }\n\n    @Test @MainActor\n    func findNoiseKey_matchesFavoriteStoredAsNpub() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let identity = try NostrIdentity.generate()\n        let noiseKey = Data((0..<32).map { UInt8(($0 + 80) & 0xFF) })\n\n        FavoritesPersistenceService.shared.addFavorite(\n            peerNoisePublicKey: noiseKey,\n            peerNostrPublicKey: identity.npub,\n            peerNickname: \"Alice\"\n        )\n        defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }\n\n        #expect(viewModel.findNoiseKey(for: identity.publicKeyHex) == noiseKey)\n    }\n\n    @Test @MainActor\n    func findNoiseKey_matchesFavoriteStoredAsHex() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let nostrHex = String(repeating: \"ab\", count: 32)\n        let noiseKey = Data((0..<32).map { UInt8(($0 + 112) & 0xFF) })\n\n        FavoritesPersistenceService.shared.addFavorite(\n            peerNoisePublicKey: noiseKey,\n            peerNostrPublicKey: nostrHex,\n            peerNickname: \"Bob\"\n        )\n        defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }\n\n        #expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)\n    }\n\n    @Test @MainActor\n    func handleFavoriteNotification_updatesFavoriteAssociation() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let identity = try NostrIdentity.generate()\n        let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })\n\n        FavoritesPersistenceService.shared.addFavorite(\n            peerNoisePublicKey: noiseKey,\n            peerNostrPublicKey: identity.npub,\n            peerNickname: \"Before\"\n        )\n        defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }\n\n        viewModel.handleFavoriteNotification(\n            content: \"FAVORITE:TRUE|NPUB:\\(identity.npub)|Alice\",\n            from: identity.publicKeyHex\n        )\n\n        let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)\n        #expect(relationship?.peerNickname == \"Alice\")\n        #expect(relationship?.peerNostrPublicKey == identity.npub)\n        #expect(relationship?.isFavorite == true)\n    }\n\n    @Test @MainActor\n    func geohashDMHelpers_exposeMappingAndDisplayName() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let nostrHex = String(repeating: \"cd\", count: 32)\n        let convKey = PeerID(nostr_: nostrHex)\n\n        viewModel.geoNicknames[nostrHex] = \"Alice\"\n        viewModel.startGeohashDM(withPubkeyHex: nostrHex)\n\n        #expect(viewModel.selectedPrivateChatPeer == convKey)\n        #expect(viewModel.fullNostrHex(forSenderPeerID: convKey) == nostrHex)\n        #expect(viewModel.geohashDisplayName(for: convKey).hasPrefix(\"Alice\"))\n        #expect(viewModel.nostrPubkeyForDisplayName(\"Alice\") == nostrHex)\n    }\n}\n\n// MARK: - Geohash Queue Tests\n\nstruct ChatViewModelGeohashQueueTests {\n\n    @Test @MainActor\n    func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n\n        viewModel.addGeohashOnlySystemMessage(\"Queued system\")\n        #expect(!viewModel.messages.contains { $0.content == \"Queued system\" })\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n\n        #expect(viewModel.messages.contains { $0.content == \"Queued system\" })\n    }\n}\n\n// MARK: - GeoDM Tests\n\nstruct ChatViewModelGeoDMTests {\n\n    @Test @MainActor\n    func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n        let senderPubkey = \"0000000000000000000000000000000000000000000000000000000000000001\"\n        let messageID = \"pm-1\"\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n        let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)\n\n        let convKey = PeerID(nostr_: senderPubkey)\n        let packet = PrivateMessagePacket(messageID: messageID, content: \"Hello\")\n        let payloadData = try #require(packet.encode(), \"Failed to encode private message\")\n        let payload = NoisePayload(type: .privateMessage, data: payloadData)\n\n        viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())\n        viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())\n\n        #expect(viewModel.privateChats[convKey]?.count == 1)\n        #expect(viewModel.sentGeoDeliveryAcks.contains(messageID))\n    }\n\n    @Test @MainActor\n    func sendGeohashDM_requiresActiveLocationChannel() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let convKey = PeerID(nostr_: \"0000000000000000000000000000000000000000000000000000000000000001\")\n\n        viewModel.sendGeohashDM(\"hello\", to: convKey)\n\n        #expect(viewModel.privateChats[convKey] == nil)\n        #expect(viewModel.messages.count == 1)\n        #expect(viewModel.messages.last?.sender == \"system\")\n    }\n\n    @Test @MainActor\n    func sendGeohashDM_missingRecipientMapping_marksFailed() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n        let convKey = PeerID(nostr_: \"0000000000000000000000000000000000000000000000000000000000000002\")\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n        viewModel.sendGeohashDM(\"hello\", to: convKey)\n\n        #expect(viewModel.privateChats[convKey]?.count == 1)\n        #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))\n    }\n\n    @Test @MainActor\n    func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessage() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n        let recipientHex = \"0000000000000000000000000000000000000000000000000000000000000003\"\n        let convKey = PeerID(nostr_: recipientHex)\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n        viewModel.nostrKeyMapping[convKey] = recipientHex\n        viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true)\n\n        viewModel.sendGeohashDM(\"hello\", to: convKey)\n\n        #expect(viewModel.privateChats[convKey]?.count == 1)\n        #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))\n        #expect(viewModel.messages.contains(where: { $0.sender == \"system\" }))\n    }\n\n    @Test @MainActor\n    func handlePrivateMessage_geohashViewingConversationRecordsReadReceipt() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n        let senderPubkey = \"0000000000000000000000000000000000000000000000000000000000000004\"\n        let convKey = PeerID(nostr_: senderPubkey)\n        let messageID = \"pm-viewing\"\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n        viewModel.selectedPrivateChatPeer = convKey\n\n        let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)\n        let packet = PrivateMessagePacket(messageID: messageID, content: \"Hello\")\n        let payloadData = try #require(packet.encode(), \"Failed to encode private message\")\n        let payload = NoisePayload(type: .privateMessage, data: payloadData)\n\n        viewModel.handlePrivateMessage(\n            payload,\n            senderPubkey: senderPubkey,\n            convKey: convKey,\n            id: identity,\n            messageTimestamp: Date()\n        )\n\n        #expect(viewModel.sentGeoDeliveryAcks.contains(messageID))\n        #expect(viewModel.sentReadReceipts.contains(messageID))\n        #expect(!viewModel.unreadPrivateMessages.contains(convKey))\n    }\n}\n\nstruct ChatViewModelMediaTransferTests {\n\n    @Test @MainActor\n    func handleTransferEvent_updatesPrivateMessageProgressAndClearsMappingOnCompletion() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let peerID = PeerID(str: \"0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10\")\n        let message = viewModel.enqueueMediaMessage(content: \"[voice] clip.m4a\", targetPeer: peerID)\n        let transferID = \"transfer-1\"\n\n        viewModel.registerTransfer(transferId: transferID, messageID: message.id)\n        viewModel.handleTransferEvent(.started(id: transferID, totalFragments: 4))\n        #expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 0, total: 4))\n\n        viewModel.handleTransferEvent(.updated(id: transferID, sentFragments: 2, totalFragments: 4))\n        #expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 2, total: 4))\n\n        viewModel.handleTransferEvent(.completed(id: transferID, totalFragments: 4))\n        #expect(isSent(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id)))\n        #expect(viewModel.messageIDToTransferId[message.id] == nil)\n        #expect(viewModel.transferIdToMessageIDs[transferID] == nil)\n    }\n\n    @Test @MainActor\n    func handleTransferEvent_cancelledRemovesOutgoingMessage() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let peerID = PeerID(str: \"1111111111111111111111111111111111111111111111111111111111111111\")\n        let message = viewModel.enqueueMediaMessage(content: \"[image] pic.jpg\", targetPeer: peerID)\n        let transferID = \"transfer-2\"\n\n        viewModel.registerTransfer(transferId: transferID, messageID: message.id)\n        viewModel.handleTransferEvent(.cancelled(id: transferID, sentFragments: 1, totalFragments: 3))\n\n        #expect(viewModel.privateChats[peerID]?.contains(where: { $0.id == message.id }) != true)\n        #expect(viewModel.messageIDToTransferId[message.id] == nil)\n    }\n\n    @Test @MainActor\n    func sendVoiceNote_outsideAllowedContextDeletesTempFile() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n        let url = FileManager.default.temporaryDirectory.appendingPathComponent(\"voice-\\(UUID().uuidString).m4a\")\n\n        try Data(\"voice\".utf8).write(to: url)\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n\n        viewModel.sendVoiceNote(at: url)\n\n        #expect(!FileManager.default.fileExists(atPath: url.path))\n        #expect(viewModel.messages.contains(where: { $0.sender == \"system\" }))\n    }\n\n    @Test @MainActor\n    func sendImage_outsideAllowedContextRunsCleanup() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n        var cleanupCalled = false\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n        viewModel.sendImage(from: URL(fileURLWithPath: \"/tmp/ignored.jpg\")) {\n            cleanupCalled = true\n        }\n\n        #expect(cleanupCalled)\n        #expect(viewModel.messages.contains(where: { $0.sender == \"system\" }))\n    }\n\n    @Test @MainActor\n    func sendVoiceNote_privateChatUsesPrivateFileTransfer() async throws {\n        let (viewModel, transport) = makeTestableViewModel()\n        let peerID = PeerID(str: \"2222222222222222222222222222222222222222222222222222222222222222\")\n        let url = FileManager.default.temporaryDirectory.appendingPathComponent(\"voice-\\(UUID().uuidString).m4a\")\n        try Data(\"voice payload\".utf8).write(to: url, options: .atomic)\n        defer { try? FileManager.default.removeItem(at: url) }\n\n        viewModel.selectedPrivateChatPeer = peerID\n        viewModel.sendVoiceNote(at: url)\n\n        let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 0.5)\n        #expect(didSend)\n        #expect(transport.sentPrivateFiles.first?.peerID == peerID)\n        #expect(viewModel.privateChats[peerID]?.last?.content.contains(\"[voice]\") == true)\n        #expect(viewModel.messageIDToTransferId.count == 1)\n        #expect(viewModel.transferIdToMessageIDs.count == 1)\n    }\n\n    @Test @MainActor\n    func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {\n        let (viewModel, transport) = makeTestableViewModel()\n        let peerID = PeerID(str: \"3333333333333333333333333333333333333333333333333333333333333333\")\n        let url = FileManager.default.temporaryDirectory.appendingPathComponent(\"voice-too-large-\\(UUID().uuidString).m4a\")\n        try Data(repeating: 0x55, count: FileTransferLimits.maxVoiceNoteBytes + 1).write(to: url, options: .atomic)\n\n        viewModel.selectedPrivateChatPeer = peerID\n        viewModel.sendVoiceNote(at: url)\n\n        let didFail = await TestHelpers.waitUntil({\n            isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)\n        }, timeout: 0.5)\n        #expect(didFail)\n        #expect(!FileManager.default.fileExists(atPath: url.path))\n        #expect(transport.sentPrivateFiles.isEmpty)\n    }\n\n    @Test @MainActor\n    func sendImage_privateChatProcessesAndTransfersImage() async throws {\n        let (viewModel, transport) = makeTestableViewModel()\n        let peerID = PeerID(str: \"4444444444444444444444444444444444444444444444444444444444444444\")\n        let sourceURL = try makeTemporaryImageURL()\n        defer { try? FileManager.default.removeItem(at: sourceURL) }\n\n        viewModel.selectedPrivateChatPeer = peerID\n        viewModel.sendImage(from: sourceURL)\n\n        let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 1.0)\n        #expect(didSend)\n        #expect(transport.sentPrivateFiles.first?.peerID == peerID)\n        #expect(transport.sentPrivateFiles.first?.packet.mimeType == \"image/jpeg\")\n        #expect(viewModel.privateChats[peerID]?.last?.content.contains(\"[image]\") == true)\n        #expect(viewModel.messageIDToTransferId.count == 1)\n    }\n\n    @Test @MainActor\n    func sendImage_invalidSourceAddsFailureSystemMessage() async throws {\n        let (viewModel, transport) = makeTestableViewModel()\n        let peerID = PeerID(str: \"5555555555555555555555555555555555555555555555555555555555555555\")\n        let url = FileManager.default.temporaryDirectory.appendingPathComponent(\"invalid-\\(UUID().uuidString).jpg\")\n        try Data(\"not-an-image\".utf8).write(to: url, options: .atomic)\n        defer { try? FileManager.default.removeItem(at: url) }\n\n        viewModel.selectedPrivateChatPeer = peerID\n        viewModel.sendImage(from: url)\n\n        let didNotify = await TestHelpers.waitUntil({\n            viewModel.messages.contains(where: { $0.sender == \"system\" && $0.content.contains(\"Failed to prepare image\") })\n        }, timeout: 2.0)\n        #expect(didNotify)\n        #expect(transport.sentPrivateFiles.isEmpty)\n        #expect(viewModel.privateChats[peerID]?.isEmpty != false)\n    }\n\n    @Test @MainActor\n    func clearTransferMapping_promotesQueuedTransferForSameID() async {\n        let (viewModel, _) = makeTestableViewModel()\n        viewModel.registerTransfer(transferId: \"transfer-queue\", messageID: \"first\")\n        viewModel.registerTransfer(transferId: \"transfer-queue\", messageID: \"second\")\n\n        viewModel.clearTransferMapping(for: \"first\")\n\n        #expect(viewModel.messageIDToTransferId[\"first\"] == nil)\n        #expect(viewModel.transferIdToMessageIDs[\"transfer-queue\"] == [\"second\"])\n        #expect(viewModel.messageIDToTransferId[\"second\"] == \"transfer-queue\")\n    }\n\n    @Test @MainActor\n    func cancelMediaSend_cancelsActiveTransferRemovesMessageAndDeletesFile() async throws {\n        let (viewModel, transport) = makeTestableViewModel()\n        let peerID = PeerID(str: \"6666666666666666666666666666666666666666666666666666666666666666\")\n        let fileName = \"cancel-\\(UUID().uuidString).m4a\"\n        let fileURL = try mediaFileURL(subdirectory: \"voicenotes/outgoing\", fileName: fileName)\n        try Data(\"cancel me\".utf8).write(to: fileURL, options: .atomic)\n\n        let message = BitchatMessage(\n            id: \"cancel-msg\",\n            sender: viewModel.nickname,\n            content: \"[voice] \\(fileName)\",\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: true,\n            recipientNickname: \"Peer\",\n            senderPeerID: viewModel.meshService.myPeerID,\n            deliveryStatus: .sending\n        )\n        viewModel.privateChats[peerID] = [message]\n        viewModel.registerTransfer(transferId: \"transfer-cancel\", messageID: message.id)\n\n        viewModel.cancelMediaSend(messageID: message.id)\n\n        #expect(transport.cancelledTransfers == [\"transfer-cancel\"])\n        #expect(viewModel.privateChats[peerID] == nil)\n        #expect(!FileManager.default.fileExists(atPath: fileURL.path))\n    }\n\n    @Test @MainActor\n    func deleteMediaMessage_removesStoredMessageAndCleansImageFile() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let peerID = PeerID(str: \"7777777777777777777777777777777777777777777777777777777777777777\")\n        let fileName = \"delete-\\(UUID().uuidString).jpg\"\n        let fileURL = try mediaFileURL(subdirectory: \"images/outgoing\", fileName: fileName)\n        try Data(\"image bytes\".utf8).write(to: fileURL, options: .atomic)\n\n        let message = BitchatMessage(\n            id: \"delete-msg\",\n            sender: viewModel.nickname,\n            content: \"[image] \\(fileName)\",\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: true,\n            recipientNickname: \"Peer\",\n            senderPeerID: viewModel.meshService.myPeerID,\n            deliveryStatus: .sent\n        )\n        viewModel.privateChats[peerID] = [message]\n        viewModel.registerTransfer(transferId: \"transfer-delete\", messageID: message.id)\n\n        viewModel.deleteMediaMessage(messageID: message.id)\n\n        #expect(viewModel.privateChats[peerID] == nil)\n        #expect(viewModel.messageIDToTransferId[message.id] == nil)\n        #expect(!FileManager.default.fileExists(atPath: fileURL.path))\n    }\n\n    @Test @MainActor\n    func makeTransferID_isPrefixedByMessageIDAndUnique() async {\n        let (viewModel, _) = makeTestableViewModel()\n\n        let first = viewModel.makeTransferID(messageID: \"base\")\n        let second = viewModel.makeTransferID(messageID: \"base\")\n\n        #expect(first.hasPrefix(\"base-\"))\n        #expect(second.hasPrefix(\"base-\"))\n        #expect(first != second)\n    }\n}\n\nprivate func base64URLEncode(_ data: Data) -> String {\n    data.base64EncodedString()\n        .replacingOccurrences(of: \"+\", with: \"-\")\n        .replacingOccurrences(of: \"/\", with: \"_\")\n        .replacingOccurrences(of: \"=\", with: \"\")\n}\n\nprivate func ackContent(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) throws -> String {\n    if let content = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(\n        type: type,\n        messageID: messageID,\n        senderPeerID: senderPeerID\n    ) {\n        return content\n    }\n    throw ChatViewModelExtensionsTestError.invalidAckContent\n}\n\nprivate func privateMessageContent(text: String, messageID: String, senderPeerID: PeerID) throws -> String {\n    if let content = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(\n        content: text,\n        messageID: messageID,\n        senderPeerID: senderPeerID\n    ) {\n        return content\n    }\n    throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent\n}\n\n@MainActor\nprivate func deliveryStatus(in viewModel: ChatViewModel, peerID: PeerID, messageID: String) -> DeliveryStatus? {\n    viewModel.privateChats[peerID]?.first(where: { $0.id == messageID })?.deliveryStatus\n}\n\nprivate func isFailed(status: DeliveryStatus?) -> Bool {\n    if case .failed = status {\n        return true\n    }\n    return false\n}\n\nprivate func isDelivered(status: DeliveryStatus?) -> Bool {\n    if case .delivered = status {\n        return true\n    }\n    return false\n}\n\nprivate func isRead(status: DeliveryStatus?) -> Bool {\n    if case .read = status {\n        return true\n    }\n    return false\n}\n\nprivate func isSent(status: DeliveryStatus?) -> Bool {\n    if case .sent = status {\n        return true\n    }\n    return false\n}\n\nprivate func isPartiallyDelivered(status: DeliveryStatus?, reached: Int, total: Int) -> Bool {\n    if case .partiallyDelivered(let actualReached, let actualTotal) = status {\n        return actualReached == reached && actualTotal == total\n    }\n    return false\n}\n\nprivate enum ChatViewModelExtensionsTestError: Error {\n    case invalidAckContent\n    case invalidPrivateMessageContent\n}\n\nprivate func mediaFileURL(subdirectory: String, fileName: String) throws -> URL {\n    let base = try FileManager.default.url(\n        for: .applicationSupportDirectory,\n        in: .userDomainMask,\n        appropriateFor: nil,\n        create: true\n    ).appendingPathComponent(\"files\", isDirectory: true)\n    let directory = base.appendingPathComponent(subdirectory, isDirectory: true)\n    try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)\n    return directory.appendingPathComponent(fileName)\n}\n\nprivate func makeTemporaryImageURL() throws -> URL {\n    let url = FileManager.default.temporaryDirectory.appendingPathComponent(\"image-\\(UUID().uuidString).png\")\n    let data = try makeImageData()\n    try data.write(to: url, options: .atomic)\n    return url\n}\n\nprivate func makeImageData() throws -> Data {\n    #if os(iOS)\n    let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)).image { context in\n        UIColor.systemTeal.setFill()\n        context.fill(CGRect(x: 0, y: 0, width: 64, height: 64))\n    }\n    guard let data = image.pngData() else {\n        throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent\n    }\n    return data\n    #else\n    let image = NSImage(size: CGSize(width: 64, height: 64))\n    image.lockFocus()\n    NSColor.systemTeal.setFill()\n    NSBezierPath(rect: CGRect(x: 0, y: 0, width: 64, height: 64)).fill()\n    image.unlockFocus()\n    guard\n        let tiffData = image.tiffRepresentation,\n        let bitmap = NSBitmapImageRep(data: tiffData),\n        let data = bitmap.representation(using: .png, properties: [:])\n    else {\n        throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent\n    }\n    return data\n    #endif\n}\n"
  },
  {
    "path": "bitchatTests/ChatViewModelRefactoringTests.swift",
    "content": "//\n// ChatViewModelRefactoringTests.swift\n// bitchatTests\n//\n// Pinning tests to characterize ChatViewModel behavior before refactoring.\n// These tests act as a safety net to ensure we don't break existing functionality.\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\nstruct ChatViewModelRefactoringTests {\n\n    // Helper to setup the environment\n    @MainActor\n    private func makePinnedViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identity: MockIdentityManager) {\n        let keychain = MockKeychain()\n        let keychainHelper = MockKeychainHelper()\n        let idBridge = NostrIdentityBridge(keychain: keychainHelper)\n        let identityManager = MockIdentityManager(keychain)\n        let transport = MockTransport()\n\n        let viewModel = ChatViewModel(\n            keychain: keychain,\n            idBridge: idBridge,\n            identityManager: identityManager,\n            transport: transport\n        )\n\n        return (viewModel, transport, identityManager)\n    }\n\n    // MARK: - Command Processor Integration \"Pinning\"\n    \n    @Test @MainActor\n    func command_msg_routesToTransport() async throws {\n        let (viewModel, transport, _) = makePinnedViewModel()\n        \n        // Setup: Use simulateConnect so ChatViewModel and UnifiedPeerService are notified\n        let peerID = PeerID(str: \"0000000000000001\")\n        transport.simulateConnect(peerID, nickname: \"alice\")\n\n        let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname(\"alice\") != nil },\n                                                     timeout: TestConstants.shortTimeout)\n        #expect(didResolve)\n        \n        // Action: User types /msg command\n        viewModel.sendMessage(\"/msg @alice Hello Private World\")\n\n        let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },\n                                                  timeout: TestConstants.shortTimeout)\n        #expect(didSend)\n        \n        // Assert:\n        // 1. Should NOT go to public transport\n        #expect(transport.sentMessages.isEmpty, \"Command should not be sent as public message\")\n        \n        // 2. Should go to private transport logic\n        #expect(transport.sentPrivateMessages.count == 1)\n        #expect(transport.sentPrivateMessages.first?.content == \"Hello Private World\")\n        #expect(transport.sentPrivateMessages.first?.peerID == peerID)\n    }\n\n    @Test @MainActor\n    func command_block_updatesIdentity() async throws {\n        let (viewModel, transport, identity) = makePinnedViewModel()\n        \n        // Setup: Use simulateConnect\n        let peerID = PeerID(str: \"0000000000000002\")\n        // Mock the fingerprint so the block command finds it\n        transport.peerFingerprints[peerID] = \"fingerprint_123\"\n        transport.simulateConnect(peerID, nickname: \"troll\")\n\n        let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname(\"troll\") != nil },\n                                                     timeout: TestConstants.shortTimeout)\n        #expect(didResolve)\n        \n        // Action\n        viewModel.sendMessage(\"/block @troll\")\n        \n        // Assert\n        // Verify identity manager was called to block \"fingerprint_123\"\n        let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: \"fingerprint_123\") },\n                                                   timeout: TestConstants.shortTimeout)\n        #expect(didBlock)\n    }\n\n    // MARK: - Message Routing Logic\n\n    @Test @MainActor\n    func routing_incomingPrivateMessage_addsToPrivateChats() async {\n        let (viewModel, _, _) = makePinnedViewModel()\n        let senderID = PeerID(str: \"sender_1\")\n\n        // Setup\n        let message = BitchatMessage(\n            id: \"msg_1\",\n            sender: \"bob\",\n            content: \"Secret\",\n            timestamp: Date(),\n            isRelay: false,\n            originalSender: nil,\n            isPrivate: true,\n            recipientNickname: \"me\",\n            senderPeerID: senderID,\n            mentions: nil\n        )\n\n        // Action: Simulate incoming private message\n        viewModel.didReceiveMessage(message)\n\n        // Wait for async processing with proper timeout\n        let found = await TestHelpers.waitUntil(\n            { viewModel.privateChats[senderID]?.first?.content == \"Secret\" },\n            timeout: TestConstants.defaultTimeout\n        )\n\n        // Assert\n        #expect(found)\n    }\n\n    @Test @MainActor\n    func routing_incomingPublicMessage_addsToPublicTimeline() async {\n        let (viewModel, _, _) = makePinnedViewModel()\n        let senderID = PeerID(str: \"sender_2\")\n\n        // Action\n        viewModel.didReceivePublicMessage(\n            from: senderID,\n            nickname: \"charlie\",\n            content: \"Public Hi\",\n            timestamp: Date(),\n            messageID: \"msg_2\"\n        )\n\n        // Wait for async processing with proper timeout\n        let found = await TestHelpers.waitUntil(\n            {\n                viewModel.timelineStore.messages(for: .mesh).contains(where: { $0.content == \"Public Hi\" })\n            },\n            timeout: TestConstants.defaultTimeout\n        )\n\n        // Assert\n        #expect(found)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/ChatViewModelTests.swift",
    "content": "//\n// ChatViewModelTests.swift\n// bitchatTests\n//\n// Tests for ChatViewModel using MockTransport for isolation.\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\n// MARK: - Test Helpers\n\n/// Creates a ChatViewModel with mock dependencies for testing\n@MainActor\nprivate func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {\n    let keychain = MockKeychain()\n    let keychainHelper = MockKeychainHelper()\n    let idBridge = NostrIdentityBridge(keychain: keychainHelper)\n    let identityManager = MockIdentityManager(keychain)\n    let transport = MockTransport()\n\n    let viewModel = ChatViewModel(\n        keychain: keychain,\n        idBridge: idBridge,\n        identityManager: identityManager,\n        transport: transport\n    )\n\n    return (viewModel, transport)\n}\n\n// MARK: - Initialization Tests\n\nstruct ChatViewModelInitializationTests {\n\n    @Test @MainActor\n    func initialization_setsDelegate() async {\n        let (viewModel, transport) = makeTestableViewModel()\n\n        // The viewModel should set itself as the transport delegate\n        #expect(transport.delegate === viewModel)\n    }\n\n    @Test @MainActor\n    func initialization_startsServices() async {\n        let (_, transport) = makeTestableViewModel()\n\n        // Services should be started during init\n        #expect(transport.startServicesCallCount == 1)\n    }\n\n    @Test @MainActor\n    func initialization_hasEmptyMessageList() async {\n        let (viewModel, _) = makeTestableViewModel()\n\n        // Initial messages may include system messages, but should be limited\n        #expect(viewModel.messages.count < 10)\n    }\n\n    @Test @MainActor\n    func initialization_setsNickname() async {\n        let (_, transport) = makeTestableViewModel()\n\n        // Nickname should be set during init\n        #expect(!transport.myNickname.isEmpty)\n    }\n}\n\n// MARK: - Message Sending Tests\n\nstruct ChatViewModelSendingTests {\n\n    @Test @MainActor\n    func sendMessage_delegatesToTransport() async {\n        let (viewModel, transport) = makeTestableViewModel()\n\n        viewModel.sendMessage(\"Hello World\")\n\n        #expect(transport.sentMessages.count == 1)\n        #expect(transport.sentMessages.first?.content == \"Hello World\")\n    }\n\n    @Test @MainActor\n    func sendMessage_emptyContent_ignored() async {\n        let (viewModel, transport) = makeTestableViewModel()\n\n        viewModel.sendMessage(\"\")\n        viewModel.sendMessage(\"   \")\n        viewModel.sendMessage(\"\\n\\t\")\n\n        #expect(transport.sentMessages.isEmpty)\n    }\n\n    @Test @MainActor\n    func sendMessage_withMentions_sendsContent() async {\n        let (viewModel, transport) = makeTestableViewModel()\n\n        viewModel.sendMessage(\"Hello @alice\")\n\n        #expect(transport.sentMessages.count == 1)\n        #expect(transport.sentMessages.first?.content == \"Hello @alice\")\n    }\n\n    @Test @MainActor\n    func sendMessage_command_notSentToTransport() async {\n        let (viewModel, transport) = makeTestableViewModel()\n\n        viewModel.sendMessage(\"/help\")\n\n        // Commands are processed locally, not sent to transport\n        #expect(transport.sentMessages.isEmpty)\n    }\n}\n\n// MARK: - Command Handling Tests\n\nstruct ChatViewModelCommandTests {\n\n    @Test @MainActor\n    func sendMessage_commandsNotSentToTransport() async {\n        let (viewModel, transport) = makeTestableViewModel()\n        let commands = [\"/nick bob\", \"/who\", \"/help\", \"/clear\"]\n\n        for command in commands {\n            transport.resetRecordings()\n            viewModel.sendMessage(command)\n            try? await Task.sleep(nanoseconds: 100_000_000)\n\n            #expect(transport.sentMessages.isEmpty)\n            #expect(transport.sentPrivateMessages.isEmpty)\n        }\n    }\n}\n\n// MARK: - Timeline Cap Tests\n\nstruct ChatViewModelTimelineCapTests {\n\n    @Test @MainActor\n    func sendMessage_trimsTimelineToCap() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let total = TransportConfig.meshTimelineCap + 5\n\n        for i in 0..<total {\n            viewModel.sendMessage(\"cap-msg-\\(i)\")\n        }\n\n        #expect(viewModel.messages.count == TransportConfig.meshTimelineCap)\n        #expect(viewModel.messages.last?.content == \"cap-msg-\\(total - 1)\")\n    }\n}\n\n// MARK: - Message Receiving Tests\n\nstruct ChatViewModelReceivingTests {\n\n    @Test @MainActor\n    func didReceiveMessage_callsDelegate() async {\n        let (_, transport) = makeTestableViewModel()\n\n        let message = BitchatMessage(\n            id: \"msg-001\",\n            sender: \"Alice\",\n            content: \"Hello from Alice\",\n            timestamp: Date(),\n            isRelay: false,\n            originalSender: nil,\n            isPrivate: false,\n            recipientNickname: nil,\n            senderPeerID: PeerID(str: \"PEER001\"),\n            mentions: nil\n        )\n\n        transport.simulateIncomingMessage(message)\n\n        // Give time for Task and pipeline processing\n        try? await Task.sleep(nanoseconds: 200_000_000)\n\n        // Message may or may not appear due to rate limiting/pipeline batching\n        // The important thing is no crash and delegate was called\n        #expect(transport.delegate != nil)\n    }\n\n    @Test @MainActor\n    func didReceivePublicMessage_addsToTimeline() async {\n        let (viewModel, transport) = makeTestableViewModel()\n\n        transport.simulateIncomingPublicMessage(\n            from: PeerID(str: \"PEER002\"),\n            nickname: \"Bob\",\n            content: \"Public hello from Bob\",\n            timestamp: Date(),\n            messageID: \"pub-001\"\n        )\n\n        let found = await TestHelpers.waitUntil({\n            viewModel.timelineStore.messages(for: .mesh).contains { $0.content == \"Public hello from Bob\" }\n        }, timeout: TestConstants.defaultTimeout)\n\n        #expect(found)\n    }\n}\n\n// MARK: - Rate Limiting Tests\n\nstruct ChatViewModelRateLimitingTests {\n\n    @Test @MainActor\n    func handlePublicMessage_rateLimitsBurstBySender() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let senderID = PeerID(str: \"1122334455667788\")\n        let now = Date()\n\n        for i in 0..<6 {\n            let message = BitchatMessage(\n                id: \"rate-\\(i)\",\n                sender: \"Spammer\",\n                content: \"rate-msg-\\(i)\",\n                timestamp: now,\n                isRelay: false,\n                originalSender: nil,\n                isPrivate: false,\n                recipientNickname: nil,\n                senderPeerID: senderID,\n                mentions: nil\n            )\n            viewModel.handlePublicMessage(message)\n        }\n\n        viewModel.publicMessagePipeline.flushIfNeeded()\n\n        let burstMessages = viewModel.messages.filter { $0.content.hasPrefix(\"rate-msg-\") }\n        #expect(burstMessages.count == 5)\n        #expect(!burstMessages.contains { $0.content == \"rate-msg-5\" })\n    }\n}\n\n// MARK: - Peer Connection Tests\n\nstruct ChatViewModelPeerTests {\n\n    @Test @MainActor\n    func didConnectToPeer_notifiesDelegate() async {\n        let (_, transport) = makeTestableViewModel()\n        let peerID = PeerID(str: \"NEWPEER\")\n\n        transport.simulateConnect(peerID, nickname: \"NewUser\")\n\n        #expect(transport.connectedPeers.contains(peerID))\n    }\n\n    @Test @MainActor\n    func didDisconnectFromPeer_notifiesDelegate() async {\n        let (_, transport) = makeTestableViewModel()\n        let peerID = PeerID(str: \"OLDPEER\")\n\n        transport.simulateConnect(peerID, nickname: \"OldUser\")\n        transport.simulateDisconnect(peerID)\n\n        #expect(!transport.connectedPeers.contains(peerID))\n    }\n\n    @Test @MainActor\n    func isPeerConnected_delegatesToTransport() async {\n        let (_, transport) = makeTestableViewModel()\n        let peerID = PeerID(str: \"TESTPEER\")\n\n        // Not connected initially\n        #expect(!transport.isPeerConnected(peerID))\n\n        transport.connectedPeers.insert(peerID)\n\n        #expect(transport.isPeerConnected(peerID))\n    }\n}\n\n// MARK: - Deduplication Integration Tests\n//\n// Note: Detailed deduplication logic is tested in MessageDeduplicationServiceTests.\n// These tests verify that ChatViewModel has a deduplication service configured.\n\nstruct ChatViewModelDeduplicationTests {\n\n    @Test @MainActor\n    func deduplicationService_isConfigured() async {\n        let (viewModel, _) = makeTestableViewModel()\n\n        // Verify the deduplication service is available and functional\n        // by checking that we can record and query content\n        let testContent = \"Test dedup content \\(UUID().uuidString)\"\n        let testDate = Date()\n\n        viewModel.deduplicationService.recordContent(testContent, timestamp: testDate)\n\n        let retrieved = viewModel.deduplicationService.contentTimestamp(for: testContent)\n        #expect(retrieved == testDate)\n    }\n\n    @Test @MainActor\n    func deduplicationService_normalizedKey_consistent() async {\n        let (viewModel, _) = makeTestableViewModel()\n\n        let content = \"Hello World\"\n        let key1 = viewModel.deduplicationService.normalizedContentKey(content)\n        let key2 = viewModel.deduplicationService.normalizedContentKey(content)\n\n        #expect(key1 == key2)\n    }\n}\n\n// MARK: - Private Chat Tests\n\nstruct ChatViewModelPrivateChatTests {\n\n    @Test @MainActor\n    func sendPrivateMessage_delegatesToTransport() async {\n        let (viewModel, transport) = makeTestableViewModel()\n        let recipientID = PeerID(str: \"RECIPIENT\")\n\n        // Set up connected peer for routing\n        transport.connectedPeers.insert(recipientID)\n        transport.peerNicknames[recipientID] = \"Recipient\"\n\n        viewModel.sendPrivateMessage(\"Secret message\", to: recipientID)\n\n        // The message routing depends on connection state and other factors\n        // At minimum, it should not crash\n        #expect(true) // If we get here without crash, the test passes\n    }\n}\n\n// MARK: - Private Chat Selection Tests\n\nstruct ChatViewModelPrivateChatSelectionTests {\n\n    @Test @MainActor\n    func openMostRelevantPrivateChat_prefersUnreadMostRecent() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let peerA = PeerID(str: \"PEER_A\")\n        let peerB = PeerID(str: \"PEER_B\")\n\n        let older = Date().addingTimeInterval(-120)\n        let newer = Date().addingTimeInterval(-30)\n\n        viewModel.privateChats = [\n            peerA: [\n                BitchatMessage(\n                    id: \"a-1\",\n                    sender: \"A\",\n                    content: \"Old\",\n                    timestamp: older,\n                    isRelay: false,\n                    isPrivate: true,\n                    recipientNickname: \"Me\",\n                    senderPeerID: peerA\n                )\n            ],\n            peerB: [\n                BitchatMessage(\n                    id: \"b-1\",\n                    sender: \"B\",\n                    content: \"New\",\n                    timestamp: newer,\n                    isRelay: false,\n                    isPrivate: true,\n                    recipientNickname: \"Me\",\n                    senderPeerID: peerB\n                )\n            ]\n        ]\n        viewModel.unreadPrivateMessages = [peerA, peerB]\n\n        viewModel.openMostRelevantPrivateChat()\n\n        #expect(viewModel.selectedPrivateChatPeer == peerB)\n    }\n\n    @Test @MainActor\n    func openMostRelevantPrivateChat_fallsBackToMostRecentChat() async {\n        let (viewModel, _) = makeTestableViewModel()\n        let peerA = PeerID(str: \"PEER_A\")\n        let peerB = PeerID(str: \"PEER_B\")\n\n        let older = Date().addingTimeInterval(-200)\n        let newer = Date().addingTimeInterval(-20)\n\n        viewModel.privateChats = [\n            peerA: [\n                BitchatMessage(\n                    id: \"a-1\",\n                    sender: \"A\",\n                    content: \"Old\",\n                    timestamp: older,\n                    isRelay: false,\n                    isPrivate: true,\n                    recipientNickname: \"Me\",\n                    senderPeerID: peerA\n                )\n            ],\n            peerB: [\n                BitchatMessage(\n                    id: \"b-1\",\n                    sender: \"B\",\n                    content: \"New\",\n                    timestamp: newer,\n                    isRelay: false,\n                    isPrivate: true,\n                    recipientNickname: \"Me\",\n                    senderPeerID: peerB\n                )\n            ]\n        ]\n\n        viewModel.openMostRelevantPrivateChat()\n\n        #expect(viewModel.selectedPrivateChatPeer == peerB)\n    }\n}\n\n// MARK: - Bluetooth State Tests\n\nstruct ChatViewModelBluetoothTests {\n\n    @Test @MainActor\n    func didUpdateBluetoothState_poweredOn_noAlert() async {\n        let (viewModel, transport) = makeTestableViewModel()\n\n        transport.simulateBluetoothStateChange(.poweredOn)\n\n        // Give time for async processing\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        #expect(!viewModel.showBluetoothAlert)\n    }\n\n    @Test @MainActor\n    func didUpdateBluetoothState_poweredOff_showsAlert() async {\n        let (viewModel, transport) = makeTestableViewModel()\n\n        transport.simulateBluetoothStateChange(.poweredOff)\n\n        // Give time for async processing\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        #expect(viewModel.showBluetoothAlert)\n    }\n\n    @Test @MainActor\n    func didUpdateBluetoothState_unauthorized_showsAlert() async {\n        let (viewModel, transport) = makeTestableViewModel()\n\n        transport.simulateBluetoothStateChange(.unauthorized)\n\n        // Give time for async processing\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        #expect(viewModel.showBluetoothAlert)\n    }\n}\n\n// MARK: - Panic Clear Tests\n\nstruct ChatViewModelPanicTests {\n\n    @Test @MainActor\n    func panicClearAllData_delegatesToTransport() async {\n        let (viewModel, transport) = makeTestableViewModel()\n\n        // Set up some state\n        transport.connectedPeers.insert(PeerID(str: \"PEER1\"))\n        viewModel.messages = [\n            BitchatMessage(\n                id: \"panic-1\",\n                sender: \"Tester\",\n                content: \"Before\",\n                timestamp: Date(),\n                isRelay: false\n            )\n        ]\n        viewModel.privateChats[PeerID(str: \"PEER1\")] = [\n            BitchatMessage(\n                id: \"pm-1\",\n                sender: \"Peer\",\n                content: \"Secret\",\n                timestamp: Date(),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Me\",\n                senderPeerID: PeerID(str: \"PEER1\")\n            )\n        ]\n        viewModel.unreadPrivateMessages.insert(PeerID(str: \"PEER1\"))\n\n        viewModel.panicClearAllData()\n\n        // After panic, emergency disconnect should be called\n        #expect(transport.emergencyDisconnectCallCount == 1)\n        #expect(viewModel.messages.isEmpty)\n        #expect(viewModel.privateChats.isEmpty)\n        #expect(viewModel.unreadPrivateMessages.isEmpty)\n        #expect(viewModel.selectedPrivateChatPeer == nil)\n    }\n}\n\n// MARK: - Service Lifecycle Tests\n\nstruct ChatViewModelLifecycleTests {\n\n    @Test @MainActor\n    func startServices_calledOnInit() async {\n        let (_, transport) = makeTestableViewModel()\n\n        #expect(transport.startServicesCallCount == 1)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/ChatViewModelTorTests.swift",
    "content": "//\n// ChatViewModelTorTests.swift\n// bitchatTests\n//\n// Tests for ChatViewModel+Tor.swift Tor lifecycle notification handlers.\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\n// MARK: - Test Helpers\n\n@MainActor\nprivate func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {\n    let keychain = MockKeychain()\n    let keychainHelper = MockKeychainHelper()\n    let idBridge = NostrIdentityBridge(keychain: keychainHelper)\n    let identityManager = MockIdentityManager(keychain)\n    let transport = MockTransport()\n\n    let viewModel = ChatViewModel(\n        keychain: keychain,\n        idBridge: idBridge,\n        identityManager: identityManager,\n        transport: transport\n    )\n\n    return (viewModel, transport)\n}\n\n// MARK: - Tor Notification Handler Tests\n\nstruct ChatViewModelTorTests {\n\n    // MARK: - handleTorWillStart Tests\n\n    @Test @MainActor\n    func handleTorWillStart_whenEnforced_setsAnnouncedFlag() async {\n        let (viewModel, _) = makeTestableViewModel()\n\n        // Precondition: flag should start false\n        #expect(!viewModel.torStatusAnnounced)\n\n        // Action: simulate Tor starting notification\n        viewModel.handleTorWillStart()\n\n        // Wait for Task to complete\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        // Assert: flag should be set (torEnforced is true in tests)\n        #expect(viewModel.torStatusAnnounced)\n    }\n\n    @Test @MainActor\n    func handleTorWillStart_whenAlreadyAnnounced_doesNotDuplicate() async {\n        let (viewModel, _) = makeTestableViewModel()\n\n        // Setup: pre-set the flag\n        viewModel.torStatusAnnounced = true\n\n        // Switch to a geohash channel so messages would be visible\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: \"u4pruydq\")))\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        let initialMessageCount = viewModel.messages.count\n\n        // Action: call handler again\n        viewModel.handleTorWillStart()\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        // Assert: no new message added (flag was already true)\n        #expect(viewModel.messages.count == initialMessageCount)\n    }\n\n    // MARK: - handleTorWillRestart Tests\n\n    @Test @MainActor\n    func handleTorWillRestart_setsPendingFlag() async {\n        let (viewModel, _) = makeTestableViewModel()\n\n        // Precondition\n        #expect(!viewModel.torRestartPending)\n\n        // Action\n        viewModel.handleTorWillRestart()\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        // Assert\n        #expect(viewModel.torRestartPending)\n    }\n\n    @Test @MainActor\n    func handleTorWillRestart_setsFlag_regardlessOfChannel() async {\n        let (viewModel, _) = makeTestableViewModel()\n\n        // Action: call handler (works regardless of channel)\n        viewModel.handleTorWillRestart()\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        // Assert: flag should be set\n        #expect(viewModel.torRestartPending)\n    }\n\n    // MARK: - handleTorDidBecomeReady Tests\n\n    @Test @MainActor\n    func handleTorDidBecomeReady_afterRestart_clearsPendingFlag() async {\n        let (viewModel, _) = makeTestableViewModel()\n\n        // Setup: simulate restart pending state\n        viewModel.torRestartPending = true\n\n        // Action\n        viewModel.handleTorDidBecomeReady()\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        // Assert: should clear pending flag\n        #expect(!viewModel.torRestartPending)\n    }\n\n    @Test @MainActor\n    func handleTorDidBecomeReady_initialStart_setsAnnouncedFlag() async {\n        let (viewModel, _) = makeTestableViewModel()\n\n        // Setup: not restarting, but initial ready not announced yet\n        viewModel.torRestartPending = false\n        viewModel.torInitialReadyAnnounced = false\n\n        // Action\n        viewModel.handleTorDidBecomeReady()\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        // Assert: should set flag (torEnforced is true in tests)\n        #expect(viewModel.torInitialReadyAnnounced)\n    }\n\n    @Test @MainActor\n    func handleTorDidBecomeReady_alreadyAnnounced_noDuplicate() async {\n        let (viewModel, _) = makeTestableViewModel()\n\n        // Setup: already announced initial ready\n        viewModel.torRestartPending = false\n        viewModel.torInitialReadyAnnounced = true\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: \"u4pruydq\")))\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        let initialMessageCount = viewModel.messages.count\n\n        // Action\n        viewModel.handleTorDidBecomeReady()\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        // Assert: no new message\n        #expect(viewModel.messages.count == initialMessageCount)\n    }\n\n    // MARK: - handleTorPreferenceChanged Tests\n\n    @Test @MainActor\n    func handleTorPreferenceChanged_resetsAllFlags() async {\n        let (viewModel, _) = makeTestableViewModel()\n\n        // Setup: set all flags\n        viewModel.torStatusAnnounced = true\n        viewModel.torInitialReadyAnnounced = true\n        viewModel.torRestartPending = true\n\n        // Action\n        viewModel.handleTorPreferenceChanged(Notification(name: .init(\"test\")))\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        // Assert: all flags reset\n        #expect(!viewModel.torStatusAnnounced)\n        #expect(!viewModel.torInitialReadyAnnounced)\n        #expect(!viewModel.torRestartPending)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/CommandProcessorTests.swift",
    "content": "import Foundation\nimport Testing\n@testable import bitchat\n\n@Suite(.serialized)\nstruct CommandProcessorTests {\n\n    @MainActor\n    @Test func slapNotFoundGrammar() {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)\n        let result = processor.process(\"/slap @system\")\n        switch result {\n        case .error(let message):\n            #expect(message == \"cannot slap system: not found\")\n        default:\n            Issue.record(\"Expected error result\")\n        }\n    }\n\n    @MainActor\n    @Test func hugNotFoundGrammar() {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)\n        let result = processor.process(\"/hug @system\")\n        switch result {\n        case .error(let message):\n            #expect(message == \"cannot hug system: not found\")\n        default:\n            Issue.record(\"Expected error result\")\n        }\n    }\n    \n    @MainActor\n    @Test func slapUsageMessage() {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)\n        let result = processor.process(\"/slap\")\n        switch result {\n        case .error(let message):\n            #expect(message == \"usage: /slap <nickname>\")\n        default:\n            Issue.record(\"Expected error result for usage message\")\n        }\n    }\n\n    @MainActor\n    @Test func msgStartsPrivateChatAndSendsMessage() async {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let context = MockCommandContextProvider()\n        let peerID = PeerID(str: \"abcd1234abcd1234\")\n        context.nicknameToPeerID[\"alice\"] = peerID\n        let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)\n\n        let result = await withSelectedChannel(.mesh) {\n            processor.process(\"/msg @alice hello there\")\n        }\n\n        switch result {\n        case .success(let message):\n            #expect(message == \"started private chat with alice\")\n        default:\n            Issue.record(\"Expected success result\")\n        }\n        #expect(context.startedPrivateChats == [peerID])\n        #expect(context.sentPrivateMessages.count == 1)\n        #expect(context.sentPrivateMessages.first?.content == \"hello there\")\n        #expect(context.sentPrivateMessages.first?.peerID == peerID)\n    }\n\n    @MainActor\n    @Test func whoInMeshListsSortedPeerNicknames() async {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let transport = MockTransport()\n        transport.peerNicknames = [\n            PeerID(str: \"b\"): \"bob\",\n            PeerID(str: \"a\"): \"alice\"\n        ]\n        let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager)\n\n        let result = await withSelectedChannel(.mesh) {\n            processor.process(\"/who\")\n        }\n\n        switch result {\n        case .success(let message):\n            #expect(message == \"online: alice, bob\")\n        default:\n            Issue.record(\"Expected success result\")\n        }\n    }\n\n    @MainActor\n    @Test func whoInGeohashListsVisibleParticipantsExcludingSelf() async throws {\n        let bridge = NostrIdentityBridge(keychain: MockKeychain())\n        let identityManager = MockIdentityManager(MockKeychain())\n        let context = MockCommandContextProvider(idBridge: bridge)\n        let geohash = \"u4pruy\"\n        let selfPubkey = try bridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased()\n        context.visibleGeoParticipants = [\n            CommandGeoParticipant(id: selfPubkey, displayName: \"me\"),\n            CommandGeoParticipant(id: String(repeating: \"b\", count: 64), displayName: \"bob\")\n        ]\n        let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)\n        let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))\n\n        let result = await withSelectedChannel(channel) {\n            processor.process(\"/who\")\n        }\n\n        switch result {\n        case .success(let message):\n            #expect(message == \"online: bob\")\n        default:\n            Issue.record(\"Expected success result\")\n        }\n    }\n\n    @MainActor\n    @Test func clearInPrivateChatRemovesOnlySelectedConversation() async {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let context = MockCommandContextProvider()\n        let activePeer = PeerID(str: \"active\")\n        let otherPeer = PeerID(str: \"other\")\n        context.selectedPrivateChatPeer = activePeer\n        context.privateChats = [\n            activePeer: [makeMessage(sender: \"alice\", content: \"secret\")],\n            otherPeer: [makeMessage(sender: \"bob\", content: \"keep\")]\n        ]\n        let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)\n\n        let result = await withSelectedChannel(.mesh) {\n            processor.process(\"/clear\")\n        }\n\n        switch result {\n        case .handled:\n            break\n        default:\n            Issue.record(\"Expected handled result\")\n        }\n        #expect(context.privateChats[activePeer] == [])\n        #expect(context.privateChats[otherPeer]?.count == 1)\n    }\n\n    @MainActor\n    @Test func clearInPublicChatClearsTimeline() async {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let context = MockCommandContextProvider()\n        let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)\n\n        let result = await withSelectedChannel(.mesh) {\n            processor.process(\"/clear\")\n        }\n\n        switch result {\n        case .handled:\n            break\n        default:\n            Issue.record(\"Expected handled result\")\n        }\n        #expect(context.clearCurrentPublicTimelineCallCount == 1)\n    }\n\n    @MainActor\n    @Test func hugInPrivateChatSendsPersonalizedMessageAndLocalEcho() async {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let context = MockCommandContextProvider(nickname: \"me\")\n        let transport = MockTransport()\n        let peerID = PeerID(str: \"abcd1234abcd1234\")\n        context.selectedPrivateChatPeer = peerID\n        context.nicknameToPeerID[\"bob\"] = peerID\n        transport.peerNicknames[peerID] = \"Bob\"\n        let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)\n\n        let result = await withSelectedChannel(.mesh) {\n            processor.process(\"/hug @bob\")\n        }\n\n        switch result {\n        case .handled:\n            break\n        default:\n            Issue.record(\"Expected handled result\")\n        }\n        #expect(transport.sentPrivateMessages.count == 1)\n        #expect(transport.sentPrivateMessages.first?.content == \"* 🫂 me hugs you *\")\n        #expect(context.localPrivateSystemMessages.first?.content == \"🫂 you hugged bob\")\n        #expect(context.localPrivateSystemMessages.first?.peerID == peerID)\n    }\n\n    @MainActor\n    @Test func slapInPublicChatSendsPublicRawAndEcho() async {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let context = MockCommandContextProvider(nickname: \"me\")\n        let peerID = PeerID(str: \"abcd1234abcd1234\")\n        context.nicknameToPeerID[\"bob\"] = peerID\n        let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)\n\n        let result = await withSelectedChannel(.mesh) {\n            processor.process(\"/slap @bob\")\n        }\n\n        switch result {\n        case .handled:\n            break\n        default:\n            Issue.record(\"Expected handled result\")\n        }\n        #expect(context.sentPublicRawMessages == [\"* 🐟 me slaps bob around a bit with a large trout *\"])\n        #expect(context.publicSystemMessages == [\"🐟 me slaps bob around a bit with a large trout\"])\n    }\n\n    @MainActor\n    @Test func blockWithoutArgsListsMeshAndGeohashBlocks() async {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let context = MockCommandContextProvider()\n        let transport = MockTransport()\n        let peerID = PeerID(str: \"abcd1234abcd1234\")\n        transport.peerNicknames[peerID] = \"bob\"\n        transport.peerFingerprints[peerID] = \"fp-bob\"\n        context.blockedUsers = [\"fp-bob\"]\n        context.visibleGeoParticipants = [\n            CommandGeoParticipant(id: String(repeating: \"c\", count: 64), displayName: \"carol\")\n        ]\n        identityManager.setNostrBlocked(String(repeating: \"c\", count: 64), isBlocked: true)\n        let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)\n\n        let result = await withSelectedChannel(.mesh) {\n            processor.process(\"/block\")\n        }\n\n        switch result {\n        case .success(let message):\n            #expect(message == \"blocked peers: bob | geohash blocks: carol\")\n        default:\n            Issue.record(\"Expected success result\")\n        }\n    }\n\n    @MainActor\n    @Test func blockAndUnblockMeshPeerUpdateIdentityState() async {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let context = MockCommandContextProvider()\n        let transport = MockTransport()\n        let peerID = PeerID(str: \"abcd1234abcd1234\")\n        transport.peerFingerprints[peerID] = \"fp-bob\"\n        context.nicknameToPeerID[\"bob\"] = peerID\n        let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)\n\n        let blockResult = await withSelectedChannel(.mesh) {\n            processor.process(\"/block @bob\")\n        }\n        switch blockResult {\n        case .success(let message):\n            #expect(message == \"blocked bob. you will no longer receive messages from them\")\n        default:\n            Issue.record(\"Expected success result\")\n        }\n        #expect(identityManager.isBlocked(fingerprint: \"fp-bob\"))\n\n        let unblockResult = await withSelectedChannel(.mesh) {\n            processor.process(\"/unblock bob\")\n        }\n        switch unblockResult {\n        case .success(let message):\n            #expect(message == \"unblocked bob\")\n        default:\n            Issue.record(\"Expected success result\")\n        }\n        #expect(!identityManager.isBlocked(fingerprint: \"fp-bob\"))\n    }\n\n    @MainActor\n    @Test func blockAndUnblockGeohashPeerUseNostrBlockList() async {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let context = MockCommandContextProvider()\n        context.displayNameToNostrPubkey[\"carol\"] = String(repeating: \"d\", count: 64)\n        let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)\n\n        let blockResult = await withSelectedChannel(.mesh) {\n            processor.process(\"/block carol\")\n        }\n        switch blockResult {\n        case .success(let message):\n            #expect(message == \"blocked carol in geohash chats\")\n        default:\n            Issue.record(\"Expected success result\")\n        }\n        #expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: \"d\", count: 64)))\n\n        let unblockResult = await withSelectedChannel(.mesh) {\n            processor.process(\"/unblock @carol\")\n        }\n        switch unblockResult {\n        case .success(let message):\n            #expect(message == \"unblocked carol in geohash chats\")\n        default:\n            Issue.record(\"Expected success result\")\n        }\n        #expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: \"d\", count: 64)))\n    }\n\n    @MainActor\n    @Test func favoriteCommandIsRejectedOutsideMesh() async {\n        let identityManager = MockIdentityManager(MockKeychain())\n        let processor = CommandProcessor(\n            contextProvider: MockCommandContextProvider(),\n            meshService: MockTransport(),\n            identityManager: identityManager\n        )\n        let channel = ChannelID.location(GeohashChannel(level: .city, geohash: \"u4pruy\"))\n\n        let result = await withSelectedChannel(channel) {\n            processor.process(\"/fav alice\")\n        }\n\n        switch result {\n        case .error(let message):\n            #expect(message == \"favorites are only for mesh peers in #mesh\")\n        default:\n            Issue.record(\"Expected error result\")\n        }\n    }\n\n    @MainActor\n    private func withSelectedChannel<T>(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T {\n        let originalChannel = LocationChannelManager.shared.selectedChannel\n        await setSelectedChannel(channel)\n        do {\n            let result = try work()\n            await setSelectedChannel(originalChannel)\n            return result\n        } catch {\n            await setSelectedChannel(originalChannel)\n            throw error\n        }\n    }\n\n    @MainActor\n    private func setSelectedChannel(_ channel: ChannelID) async {\n        LocationChannelManager.shared.select(channel)\n        for _ in 0..<40 {\n            if LocationChannelManager.shared.selectedChannel == channel {\n                return\n            }\n            await Task.yield()\n            try? await Task.sleep(nanoseconds: 5_000_000)\n        }\n    }\n\n    private func makeMessage(sender: String, content: String) -> BitchatMessage {\n        BitchatMessage(\n            sender: sender,\n            content: content,\n            timestamp: Date(timeIntervalSince1970: 1_700_000_000),\n            isRelay: false\n        )\n    }\n}\n\n@MainActor\nprivate final class MockCommandContextProvider: CommandContextProvider {\n    var nickname: String\n    var selectedPrivateChatPeer: PeerID?\n    var blockedUsers: Set<String> = []\n    var privateChats: [PeerID: [BitchatMessage]] = [:]\n    let idBridge: NostrIdentityBridge\n\n    var nicknameToPeerID: [String: PeerID] = [:]\n    var visibleGeoParticipants: [CommandGeoParticipant] = []\n    var displayNameToNostrPubkey: [String: String] = [:]\n\n    private(set) var startedPrivateChats: [PeerID] = []\n    private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = []\n    private(set) var clearCurrentPublicTimelineCallCount = 0\n    private(set) var sentPublicRawMessages: [String] = []\n    private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []\n    private(set) var publicSystemMessages: [String] = []\n    private(set) var toggledFavorites: [PeerID] = []\n    private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []\n\n    init(nickname: String = \"tester\", idBridge: NostrIdentityBridge = NostrIdentityBridge(keychain: MockKeychain())) {\n        self.nickname = nickname\n        self.idBridge = idBridge\n    }\n\n    func getPeerIDForNickname(_ nickname: String) -> PeerID? {\n        nicknameToPeerID[nickname]\n    }\n\n    func getVisibleGeoParticipants() -> [CommandGeoParticipant] {\n        visibleGeoParticipants\n    }\n\n    func nostrPubkeyForDisplayName(_ displayName: String) -> String? {\n        displayNameToNostrPubkey[displayName]\n    }\n\n    func startPrivateChat(with peerID: PeerID) {\n        startedPrivateChats.append(peerID)\n    }\n\n    func sendPrivateMessage(_ content: String, to peerID: PeerID) {\n        sentPrivateMessages.append((content, peerID))\n    }\n\n    func clearCurrentPublicTimeline() {\n        clearCurrentPublicTimelineCallCount += 1\n    }\n\n    func sendPublicRaw(_ content: String) {\n        sentPublicRawMessages.append(content)\n    }\n\n    func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {\n        localPrivateSystemMessages.append((content, peerID))\n    }\n\n    func addPublicSystemMessage(_ content: String) {\n        publicSystemMessages.append(content)\n    }\n\n    func toggleFavorite(peerID: PeerID) {\n        toggledFavorites.append(peerID)\n    }\n\n    func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {\n        favoriteNotifications.append((peerID, isFavorite))\n    }\n}\n"
  },
  {
    "path": "bitchatTests/EndToEnd/PrivateChatE2ETests.swift",
    "content": "//\n// PrivateChatE2ETests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Testing\nimport CryptoKit\nimport struct Foundation.UUID\n@testable import bitchat\n\nstruct PrivateChatE2ETests {\n    \n    private let alice: MockBLEService\n    private let bob: MockBLEService\n    private let charlie: MockBLEService\n    private let mockKeychain = MockKeychain()\n    private let bus = MockBLEBus()\n    \n    init() {\n        // Create services with unique peer IDs to avoid any collision\n        alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus)\n        bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)\n        charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)\n    }\n    \n    // MARK: - Basic Private Messaging Tests\n\n    @Test func simplePrivateMessageShouldNotBeSentWithoutConnection() async {\n        // Intentionally not connecting alice and bob to test\n\n        var bobReceivedMessage = false\n\n        await confirmation(\"Bob should not receive a private message\", expectedCount: 0) { bobReceivesMessage in\n            bob.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 &&\n                   message.isPrivate &&\n                   message.sender == TestConstants.testNickname1 {\n                    bobReceivedMessage = true\n                    bobReceivesMessage()\n                }\n            }\n\n            // Alice sends private message to Bob\n            alice.sendPrivateMessage(\n                TestConstants.testMessage1,\n                to: bob.peerID,\n                recipientNickname: TestConstants.testNickname2\n            )\n\n            // Wait a bit to ensure message would have been delivered if it was going to be\n            try? await sleep(0.1)\n        }\n\n        #expect(!bobReceivedMessage, \"Bob should not have received the message\")\n    }\n\n    @Test func simplePrivateMessage() async {\n        alice.simulateConnection(with: bob)\n        \n        await confirmation(\"Bob receives private message\") { bobReceivesMessage in\n            bob.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 &&\n                   message.isPrivate &&\n                   message.sender == TestConstants.testNickname1 {\n                    bobReceivesMessage()\n                }\n            }\n            \n            // Alice sends private message to Bob\n            alice.sendPrivateMessage(\n                TestConstants.testMessage1,\n                to: bob.peerID,\n                recipientNickname: TestConstants.testNickname2\n            )\n        }\n    }\n    \n    @Test func privateMessageNotReceivedByOthers() async {\n        alice.simulateConnection(with: bob)\n        alice.simulateConnection(with: charlie)\n        \n        await confirmation(\"Bob receives private message\") { bobReceivesMessage in\n            bob.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 && message.isPrivate {\n                    bobReceivesMessage()\n                }\n            }\n            \n            charlie.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 {\n                    Issue.record(\"Charlie should not receive\")\n                }\n            }\n\n            alice.sendPrivateMessage(\n                TestConstants.testMessage1,\n                to: bob.peerID,\n                recipientNickname: TestConstants.testNickname2\n            )\n        }\n    }\n    \n    // MARK: - End-to-End Encryption Tests\n    \n    @Test func privateMessageEncryption() async {\n        alice.simulateConnection(with: bob)\n        \n        // Setup Noise sessions\n        let aliceKey = Curve25519.KeyAgreement.PrivateKey()\n        let bobKey = Curve25519.KeyAgreement.PrivateKey()\n        \n        let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)\n        let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)\n        \n        // Establish encrypted session\n        do {\n            let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID)\n            let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)!\n            let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)!\n            _ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3)\n        } catch {\n            Issue.record(\"Failed to establish Noise session: \\(error)\")\n        }\n        \n        await confirmation(\"Encrypted message received\") { receiveEncryptedMessage in\n            // Setup packet handlers for encryption\n            alice.packetDeliveryHandler = { packet in\n                // Encrypt outgoing private messages\n                if packet.type == 0x01,\n                   let message = BitchatMessage(packet.payload),\n                   message.isPrivate {\n                    do {\n                        let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID)\n                        let encryptedPacket = BitchatPacket(\n                            type: 0x02, // Encrypted message type\n                            senderID: packet.senderID,\n                            recipientID: packet.recipientID,\n                            timestamp: packet.timestamp,\n                            payload: encrypted,\n                            signature: packet.signature,\n                            ttl: packet.ttl\n                        )\n                        self.bob.simulateIncomingPacket(encryptedPacket)\n                    } catch {\n                        Issue.record(\"Encryption failed: \\(error)\")\n                    }\n                }\n            }\n            \n            bob.packetDeliveryHandler = { packet in\n                // Decrypt incoming encrypted messages\n                if packet.type == 0x02 {\n                    do {\n                        let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID)\n                        if let message = BitchatMessage(decrypted) {\n                            #expect(message.content == TestConstants.testMessage1)\n                            #expect(message.isPrivate)\n                            receiveEncryptedMessage()\n                        }\n                    } catch {\n                        Issue.record(\"Decryption failed: \\(error)\")\n                    }\n                }\n            }\n            \n            // Send encrypted private message\n            alice.sendPrivateMessage(\n                TestConstants.testMessage1,\n                to: bob.peerID,\n                recipientNickname: TestConstants.testNickname2\n            )\n        }\n    }\n    \n    // MARK: - Multi-hop Private Message Tests\n    \n    @Test func privateMessageRelay() async {\n        // Setup: Alice -> Bob -> Charlie\n        alice.simulateConnection(with: bob)\n        bob.simulateConnection(with: charlie)\n        \n        await confirmation(\"Private message relayed to Charlie\") { charlieReceivesMessage in\n            // Bob relays private messages for Charlie\n            bob.packetDeliveryHandler = { packet in\n                if let recipientID = packet.recipientID,\n                   PeerID(data: recipientID) == charlie.peerID {\n                    // Relay to Charlie\n                    var relayPacket = packet\n                    relayPacket.ttl = packet.ttl - 1\n                    charlie.simulateIncomingPacket(relayPacket)\n                }\n            }\n            \n            charlie.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 &&\n                    message.isPrivate &&\n                    message.recipientNickname == TestConstants.testNickname3 {\n                    charlieReceivesMessage()\n                }\n            }\n            \n            // Alice sends private message to Charlie (through Bob)\n            alice.sendPrivateMessage(\n                TestConstants.testMessage1,\n                to: charlie.peerID,\n                recipientNickname: TestConstants.testNickname3\n            )\n        }\n    }\n    \n    // MARK: - Performance Tests\n    \n    @Test func privateMessageThroughput() async {\n        alice.simulateConnection(with: bob)\n        \n        let messageCount = 100\n        var receivedCount = 0\n        \n        await confirmation(\"All private messages received\") { receivePrivateMessage in\n            bob.messageDeliveryHandler = { message in\n                if message.isPrivate && message.sender == TestConstants.testNickname1 {\n                    receivedCount += 1\n                    if receivedCount == messageCount {\n                        receivePrivateMessage()\n                    }\n                }\n            }\n            \n            // Send many private messages\n            for i in 0..<messageCount {\n                alice.sendPrivateMessage(\n                    \"Private message \\(i)\",\n                    to: bob.peerID,\n                    recipientNickname: TestConstants.testNickname2\n                )\n            }\n        }\n    }\n\n    @Test func largePrivateMessage() async {\n        alice.simulateConnection(with: bob)\n\n        await confirmation(\"Large private message received\") { receiveLargeMessage in\n            bob.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testLongMessage && message.isPrivate {\n                    receiveLargeMessage()\n                }\n            }\n\n            alice.sendPrivateMessage(\n                TestConstants.testLongMessage,\n                to: bob.peerID,\n                recipientNickname: TestConstants.testNickname2\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "bitchatTests/EndToEnd/PublicChatE2ETests.swift",
    "content": "//\n// PublicChatE2ETests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Testing\nimport struct Foundation.UUID\n@testable import bitchat\n\nstruct PublicChatE2ETests {\n    \n    private let alice: MockBLEService\n    private let bob: MockBLEService\n    private let charlie: MockBLEService\n    private let david: MockBLEService\n    private let bus = MockBLEBus()\n    \n    private var receivedMessages: [String: [BitchatMessage]] = [:]\n    \n    init() {\n        // Create mock services with unique peer IDs to avoid any collision\n        alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus)\n        bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)\n        charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)\n        david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4, bus: bus)\n    }\n    \n    // MARK: - Basic Broadcasting Tests\n    \n    @Test func simplePublicMessage() async {\n        alice.simulateConnection(with: bob)\n        \n        await confirmation(\"Bob receives message\") { bobReceivesMessage in\n            bob.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 && message.sender == TestConstants.testNickname1 {\n                    bobReceivesMessage()\n                }\n            }\n            \n            alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)\n        }\n    }\n    \n    @Test func multiRecipientBroadcast() async {\n        alice.simulateConnection(with: bob)\n        alice.simulateConnection(with: charlie)\n        \n        var bobReceivedMessage = false\n        var charlieReceivedMessage = false\n        \n        await confirmation(\"Both recieve message\", expectedCount: 2) { receiveMessage in\n            bob.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 {\n                    if !bobReceivedMessage {\n                        bobReceivedMessage = true\n                        receiveMessage()\n                    } else {\n                        Issue.record(\"Bob received more than once\")\n                    }\n                }\n            }\n            \n            charlie.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 {\n                    if !charlieReceivedMessage {\n                        charlieReceivedMessage = true\n                        receiveMessage()\n                    } else {\n                        Issue.record(\"Charlie received more than once\")\n                    }\n                }\n            }\n            \n            alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)\n        }\n    }\n    \n    // MARK: - Message Routing and Relay Tests\n    \n    @Test func messageRelayChain() async {\n        // Linear topology: Alice -> Bob -> Charlie\n        alice.simulateConnection(with: bob)\n        bob.simulateConnection(with: charlie)\n\n        await confirmation(\"Charlie receives relayed message\") { charlieReceivesMessage in\n            // Set up relay in Bob\n            bob.packetDeliveryHandler = { packet in\n                // Bob should relay to Charlie\n                if let message = BitchatMessage(packet.payload),\n                   message.sender == TestConstants.testNickname1 {\n\n                    // Create relay message\n                    let relayMessage = BitchatMessage(\n                        id: message.id,\n                        sender: message.sender,\n                        content: message.content,\n                        timestamp: message.timestamp,\n                        isRelay: true,\n                        originalSender: message.sender,\n                        isPrivate: message.isPrivate,\n                        recipientNickname: message.recipientNickname,\n                        senderPeerID: message.senderPeerID,\n                        mentions: message.mentions\n                    )\n\n                    if let relayPayload = relayMessage.toBinaryPayload() {\n                        let relayPacket = BitchatPacket(\n                            type: packet.type,\n                            senderID: packet.senderID,\n                            recipientID: packet.recipientID,\n                            timestamp: packet.timestamp,\n                            payload: relayPayload,\n                            signature: packet.signature,\n                            ttl: packet.ttl - 1\n                        )\n\n                        // Simulate relay to Charlie\n                        self.charlie.simulateIncomingPacket(relayPacket)\n                    }\n                }\n            }\n\n            charlie.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 &&\n                   message.originalSender == TestConstants.testNickname1 &&\n                   message.isRelay {\n                    charlieReceivesMessage()\n                }\n            }\n\n            alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)\n        }\n    }\n    \n    @Test func multiHopRelay() async {\n        // Topology: Alice -> Bob -> Charlie -> David\n        alice.simulateConnection(with: bob)\n        bob.simulateConnection(with: charlie)\n        charlie.simulateConnection(with: david)\n        \n        await confirmation(\"David receives multi-hop message\") { davidReceivesMessage in\n            // Set up relay chain\n            setupRelayHandler(bob, nextHops: [charlie])\n            setupRelayHandler(charlie, nextHops: [david])\n            \n            david.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 &&\n                   message.originalSender == TestConstants.testNickname1 &&\n                   message.isRelay {\n                    davidReceivesMessage()\n                }\n            }\n            \n            alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)\n        }\n    }\n    \n    // MARK: - TTL (Time To Live) Tests\n    \n    @Test func ttlDecrement() async {\n        // Create a chain longer than TTL\n        let nodes = [alice, bob, charlie, david]\n        \n        // Connect in chain\n        for i in 0..<nodes.count-1 {\n            nodes[i].simulateConnection(with: nodes[i+1])\n            if i > 0 && i < nodes.count-1 {\n                setupRelayHandler(nodes[i], nextHops: [nodes[i+1]])\n            }\n        }\n        \n        await confirmation(\"Message dropped due to TTL\", expectedCount: 0) { receiveMessage in\n            david.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 {\n                    receiveMessage() // This should not happen\n                }\n            }\n            \n            // Inject at Bob with TTL=2 so Charlie sees it (TTL->1) and does not relay to David\n            let msg = TestHelpers.createTestMessage(\n                content: TestConstants.testMessage1,\n                sender: TestConstants.testNickname1,\n                senderPeerID: alice.peerID\n            )\n\n            if let payload = msg.toBinaryPayload() {\n                let pkt = TestHelpers.createTestPacket(senderID: alice.peerID, payload: payload, ttl: 2)\n                bob.simulateIncomingPacket(pkt)\n            }\n        }\n    }\n    \n    @Test func zeroTTLNotRelayed() async {\n        alice.simulateConnection(with: bob)\n        bob.simulateConnection(with: charlie)\n        \n        await confirmation(\"Zero TTL message not relayed\", expectedCount: 0) { receiveMessage in\n            charlie.messageDeliveryHandler = { message in\n                if message.content == \"Zero TTL message\" {\n                    receiveMessage() // Should not happen\n                }\n            }\n            \n            // Create packet with TTL=0\n            let message = TestHelpers.createTestMessage(content: \"Zero TTL message\")\n            if let payload = message.toBinaryPayload() {\n                let packet = TestHelpers.createTestPacket(payload: payload, ttl: 0)\n                alice.simulateIncomingPacket(packet)\n            }\n        }\n    }\n    \n    // MARK: - Duplicate Detection Tests\n    \n    @Test func duplicateMessagePrevention() async {\n        alice.simulateConnection(with: bob)\n        \n        var messageCount = 0\n        \n        await confirmation(\"Only one message received\") { receiveMessage in\n            bob.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 {\n                    receiveMessage()\n                    messageCount += 1\n                    if messageCount == 1 {\n                        // Send duplicate after small delay\n                        alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil, messageID: message.id)\n                    } else {\n                        Issue.record(\"Duplicate message was not filtered\")\n                    }\n                }\n            }\n            \n            // Send original message\n            alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)\n        }\n    }\n    \n    @Test func duplicateContentAsNewMessageNotPrevented() async {\n        alice.simulateConnection(with: bob)\n        \n        var messageCount = 0\n        \n        await confirmation(\"Only one message received\", expectedCount: 2) { receiveMessage in\n            bob.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testMessage1 {\n                    receiveMessage()\n                    messageCount += 1\n                    if messageCount == 1 {\n                        // Send the same content as a new message\n                        alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)\n                    }\n                }\n            }\n            \n            // Send original message\n            alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)\n        }\n    }\n    \n    // MARK: - Mention Tests\n    \n    @Test func messageWithMentions() async {\n        alice.simulateConnection(with: bob)\n        alice.simulateConnection(with: charlie)\n        \n        var mentionedUsers: Set<String> = []\n        \n        await confirmation(\"Mentioned users receive notification\", expectedCount: 2) { receiveMention in\n            bob.messageDeliveryHandler = { message in\n                if message.mentions?.contains(TestConstants.testNickname2) == true {\n                    mentionedUsers.insert(TestConstants.testNickname2)\n                    receiveMention()\n                }\n            }\n            \n            charlie.messageDeliveryHandler = { message in\n                if message.mentions?.contains(TestConstants.testNickname3) == true {\n                    mentionedUsers.insert(TestConstants.testNickname3)\n                    receiveMention()\n                }\n            }\n            \n            // Alice mentions Bob and Charlie\n            alice.sendMessage(\n                \"Hey @\\(TestConstants.testNickname2) and @\\(TestConstants.testNickname3)!\",\n                mentions: [TestConstants.testNickname2, TestConstants.testNickname3],\n                to: nil\n            )\n        }\n        \n        #expect(mentionedUsers == [TestConstants.testNickname2, TestConstants.testNickname3])\n    }\n    \n    // MARK: - Network Topology Tests\n    \n    @Test func meshTopologyBroadcast() async {\n        // Create mesh: Everyone connected to everyone\n        let nodes = [alice, bob, charlie, david]\n        for i in 0..<nodes.count {\n            for j in i+1..<nodes.count {\n                nodes[i].simulateConnection(with: nodes[j])\n            }\n        }\n        \n        await confirmation(\"All nodes receive message\", expectedCount: 3) { receiveMessage in\n            for (index, node) in nodes.enumerated() where index > 0 {\n                node.messageDeliveryHandler = { message in\n                    if message.content == TestConstants.testMessage1 {\n                        receiveMessage()\n                    }\n                }\n            }\n            \n            alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)\n        }\n    }\n    \n    @Test func partialMeshRelay() async {\n        // Partial mesh: Alice -> Bob, Bob -> Charlie, Charlie -> David, David -> Alice\n        alice.simulateConnection(with: bob)\n        bob.simulateConnection(with: charlie)\n        charlie.simulateConnection(with: david)\n        david.simulateConnection(with: alice)\n        \n        // Setup relay handlers\n        setupRelayHandler(bob, nextHops: [charlie])\n        setupRelayHandler(charlie, nextHops: [david])\n        setupRelayHandler(david, nextHops: [alice])\n        \n        await confirmation(\"Message reaches all nodes once\", expectedCount: 3) { receiveMessage in\n            for node in [bob, charlie, david] {\n                node.messageDeliveryHandler = { message in\n                    if message.content == TestConstants.testMessage1 {\n                        receiveMessage()\n                    }\n                }\n            }\n            \n            alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)\n        }\n    }\n    \n    // MARK: - Performance and Stress Tests\n    \n    @Test func highVolumeMessaging() async {\n        alice.simulateConnection(with: bob)\n        \n        let messageCount = 100\n        \n        await confirmation(\"All messages received\", expectedCount: messageCount) { receiveMessage in\n            bob.messageDeliveryHandler = { message in\n                if message.sender == TestConstants.testNickname1 {\n                    receiveMessage()\n                }\n            }\n            \n            // Send many messages rapidly\n            for i in 0..<messageCount {\n                alice.sendMessage(\"Message \\(i)\", mentions: [], to: nil)\n            }\n        }\n    }\n    \n    @Test func largeMessageBroadcast() async {\n        alice.simulateConnection(with: bob)\n        \n        await confirmation(\"Large message received\") { receiveLargeMessage in\n            bob.messageDeliveryHandler = { message in\n                if message.content == TestConstants.testLongMessage {\n                    receiveLargeMessage()\n                }\n            }\n            \n            alice.sendMessage(TestConstants.testLongMessage, mentions: [], to: nil)\n        }\n    }\n    \n    // MARK: - Helper Methods\n\n    private func setupRelayHandler(_ node: MockBLEService, nextHops: [MockBLEService]) {\n        node.packetDeliveryHandler = { packet in\n            // Check if should relay\n            guard packet.ttl > 1 else { return }\n\n            if let message = BitchatMessage(packet.payload) {\n                // Don't relay own messages\n                guard message.senderPeerID != node.peerID else { return }\n                \n                // Create relay message\n                let relayMessage = BitchatMessage(\n                    id: message.id,\n                    sender: message.sender,\n                    content: message.content,\n                    timestamp: message.timestamp,\n                    isRelay: true,\n                    originalSender: message.isRelay ? message.originalSender : message.sender,\n                    isPrivate: message.isPrivate,\n                    recipientNickname: message.recipientNickname,\n                    senderPeerID: message.senderPeerID,\n                    mentions: message.mentions\n                )\n                \n                if let relayPayload = relayMessage.toBinaryPayload() {\n                    let relayPacket = BitchatPacket(\n                        type: packet.type,\n                        senderID: node.peerID.id.data(using: .utf8)!,\n                        recipientID: packet.recipientID,\n                        timestamp: packet.timestamp,\n                        payload: relayPayload,\n                        signature: packet.signature,\n                        ttl: packet.ttl - 1\n                    )\n                    \n                    // Relay to next hops\n                    for nextHop in nextHops {\n                        nextHop.simulateIncomingPacket(relayPacket)\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Features/ImageUtilsTests.swift",
    "content": "import Testing\nimport Foundation\n#if os(iOS)\nimport UIKit\n#else\nimport AppKit\n#endif\n@testable import bitchat\n\nprivate func makeTemporaryFileURL(_ name: String) -> URL {\n    FileManager.default.temporaryDirectory.appendingPathComponent(name)\n}\n\n#if os(iOS)\nprivate func makePlatformImage(size: CGSize) -> UIImage {\n    UIGraphicsImageRenderer(size: size).image { context in\n        UIColor.systemTeal.setFill()\n        context.fill(CGRect(origin: .zero, size: size))\n    }\n}\n#else\nprivate func makePlatformImage(size: CGSize) -> NSImage {\n    let image = NSImage(size: size)\n    image.lockFocus()\n    NSColor.systemTeal.setFill()\n    NSBezierPath(rect: CGRect(origin: .zero, size: size)).fill()\n    image.unlockFocus()\n    return image\n}\n#endif\n\nstruct ImageUtilsTests {\n    @Test\n    func processImage_rejectsOversizedSourceFile() throws {\n        let url = makeTemporaryFileURL(\"image-too-large.bin\")\n        try Data(repeating: 0xFF, count: 10 * 1024 * 1024 + 1).write(to: url, options: .atomic)\n        defer { try? FileManager.default.removeItem(at: url) }\n\n        #expect(throws: ImageUtilsError.self) {\n            try ImageUtils.processImage(at: url)\n        }\n    }\n\n    @Test\n    func processImage_rejectsInvalidImageData() throws {\n        let url = makeTemporaryFileURL(\"image-invalid.bin\")\n        try Data(\"not-an-image\".utf8).write(to: url, options: .atomic)\n        defer { try? FileManager.default.removeItem(at: url) }\n\n        #expect(throws: ImageUtilsError.self) {\n            try ImageUtils.processImage(at: url)\n        }\n    }\n\n    @Test\n    func processImage_writesCompressedJpeg() throws {\n        let image = makePlatformImage(size: CGSize(width: 1024, height: 768))\n        let outputURL = try ImageUtils.processImage(image, maxDimension: 256)\n        defer { try? FileManager.default.removeItem(at: outputURL) }\n\n        let data = try Data(contentsOf: outputURL)\n\n        #expect(outputURL.pathExtension.lowercased() == \"jpg\")\n        #expect(data.starts(with: Data([0xFF, 0xD8])))\n        #expect(data.count > 0)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/FontBitchatTests.swift",
    "content": "import SwiftUI\nimport XCTest\n@testable import bitchat\n\nfinal class FontBitchatTests: XCTestCase {\n//    func testMonospacedMapping() {\n//        XCTAssertEqual(Font.bitchatSystem(size: 10, design: .monospaced), Font.system(.caption2, design: .monospaced))\n//        XCTAssertEqual(Font.bitchatSystem(size: 14, design: .monospaced), Font.system(.body, design: .monospaced))\n//        XCTAssertEqual(Font.bitchatSystem(size: 20, design: .monospaced), Font.system(.title2, design: .monospaced))\n//    }\n//\n//    func testWeightIsPreserved() {\n//        let bold = Font.bitchatSystem(size: 14, weight: .bold, design: .monospaced)\n//        XCTAssertEqual(bold, Font.system(.body, design: .monospaced).weight(.bold))\n//    }\n}\n"
  },
  {
    "path": "bitchatTests/Fragmentation/FragmentationTests.swift",
    "content": "//\n// FragmentationTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Testing\nimport Foundation\nimport CoreBluetooth\n@testable import bitchat\n\nstruct FragmentationTests {\n    \n    private let mockKeychain: MockKeychain\n    private let mockIdentityManager: MockIdentityManager\n    private let idBridge: NostrIdentityBridge\n    \n    init() {\n        mockKeychain = MockKeychain()\n        mockIdentityManager = MockIdentityManager(mockKeychain)\n        idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())\n    }\n    \n    @Test(\"Reassembly from fragments delivers a public message\")\n    func reassemblyFromFragmentsDeliversPublicMessage() async throws {\n        let ble = BLEService(\n            keychain: mockKeychain,\n            idBridge: idBridge,\n            identityManager: mockIdentityManager,\n            initializeBluetoothManagers: false\n        )\n        let capture = CaptureDelegate()\n        ble.delegate = capture\n\n        // Construct a big packet (3KB) from a remote sender (not our own ID)\n        let remoteShortID = PeerID(str: \"1122334455667788\")\n        let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)\n\n        // Use a small fragment size to ensure multiple pieces\n        let fragments = fragmentPacket(original, fragmentSize: 400)\n\n        // Shuffle fragments to simulate out-of-order arrival\n        let shuffled = fragments.shuffled()\n\n        // Send fragments sequentially with small delays (no fire-and-forget Tasks)\n        for (i, fragment) in shuffled.enumerated() {\n            if i > 0 {\n                try await Task.sleep(for: .milliseconds(5))\n            }\n            ble._test_handlePacket(fragment, fromPeerID: remoteShortID)\n        }\n\n        // Wait for delegate callback with proper timeout\n        try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))\n\n        #expect(capture.publicMessages.count == 1)\n        #expect(capture.publicMessages.first?.content.count == 3_000)\n    }\n    \n    @Test(\"Duplicate fragment does not break reassembly\")\n    func duplicateFragmentDoesNotBreakReassembly() async throws {\n        let ble = BLEService(\n            keychain: mockKeychain,\n            idBridge: idBridge,\n            identityManager: mockIdentityManager,\n            initializeBluetoothManagers: false\n        )\n        let capture = CaptureDelegate()\n        ble.delegate = capture\n\n        let remoteShortID = PeerID(str: \"A1B2C3D4E5F60708\")\n        let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)\n        var frags = fragmentPacket(original, fragmentSize: 300)\n\n        // Duplicate one fragment\n        if let dup = frags.first {\n            frags.insert(dup, at: 1)\n        }\n\n        // Send fragments sequentially with small delays (no fire-and-forget Tasks)\n        for (i, fragment) in frags.enumerated() {\n            if i > 0 {\n                try await Task.sleep(for: .milliseconds(5))\n            }\n            ble._test_handlePacket(fragment, fromPeerID: remoteShortID)\n        }\n\n        // Wait for delegate callback with proper timeout\n        try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))\n\n        #expect(capture.publicMessages.count == 1)\n        #expect(capture.publicMessages.first?.content.count == 2048)\n    }\n\n    @Test(\"Max-sized file transfer survives reassembly\")\n    func maxSizedFileTransferSurvivesReassembly() async throws {\n        let ble = BLEService(\n            keychain: mockKeychain,\n            idBridge: idBridge,\n            identityManager: mockIdentityManager,\n            initializeBluetoothManagers: false\n        )\n        let capture = CaptureDelegate()\n        ble.delegate = capture\n\n        let remoteID = PeerID(str: \"CAFEBABECAFEBABE\")\n        let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes)\n        let filePacket = BitchatFilePacket(\n            fileName: \"limit.bin\",\n            fileSize: UInt64(fileContent.count),\n            mimeType: \"application/octet-stream\",\n            content: fileContent\n        )\n        let encoded = try #require(filePacket.encode(), \"File packet encoding failed\")\n\n        let packet = BitchatPacket(\n            type: MessageType.fileTransfer.rawValue,\n            senderID: Data(hexString: remoteID.id) ?? Data(),\n            recipientID: nil,\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: encoded,\n            signature: nil,\n            ttl: 7,\n            version: 2\n        )\n\n        let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false)\n        #expect(!fragments.isEmpty)\n\n        for (i, fragment) in fragments.enumerated() {\n            let delay = 5 * Double(i) * 0.001\n            Task {\n                try await sleep(delay)\n                ble._test_handlePacket(fragment, fromPeerID: remoteID)\n            }\n        }\n\n        try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2))\n\n        let message = try #require(capture.receivedMessages.first, \"Expected file transfer message\")\n        #expect(message.content.hasPrefix(\"[file]\"))\n\n        if let fileName = message.content.split(separator: \" \").last {\n            let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)\n            let filesRoot = base.appendingPathComponent(\"files\", isDirectory: true)\n            let incoming = filesRoot.appendingPathComponent(\"files/incoming\", isDirectory: true)\n            let url = incoming.appendingPathComponent(String(fileName))\n            try? FileManager.default.removeItem(at: url)\n        }\n    }\n    \n    @Test(\"Invalid fragment header is ignored\")\n    func invalidFragmentHeaderIsIgnored() async throws {\n        let ble = BLEService(\n            keychain: mockKeychain,\n            idBridge: idBridge,\n            identityManager: mockIdentityManager,\n            initializeBluetoothManagers: false\n        )\n        let capture = CaptureDelegate()\n        ble.delegate = capture\n        \n        let remoteShortID = PeerID(str: \"0011223344556677\")\n        let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)\n        let fragments = fragmentPacket(original, fragmentSize: 250)\n        \n        // Corrupt one fragment: make payload too short (header incomplete)\n        var corrupted = fragments\n        if !corrupted.isEmpty {\n            var p = corrupted[0]\n            p = BitchatPacket(\n                type: p.type,\n                senderID: p.senderID,\n                recipientID: p.recipientID,\n                timestamp: p.timestamp,\n                payload: Data([0x00, 0x01, 0x02]), // invalid header\n                signature: nil,\n                ttl: p.ttl\n            )\n            corrupted[0] = p\n        }\n        \n        for (i, fragment) in corrupted.enumerated() {\n            let delay = 5 * Double(i) * 0.001\n            Task {\n                try await sleep(delay)\n                ble._test_handlePacket(fragment, fromPeerID: remoteShortID)\n            }\n        }\n        \n        // Allow async processing\n        try await sleep(0.5)\n\n        // Should not deliver since one fragment is invalid and reassembly can't complete\n        #expect(capture.publicMessages.isEmpty)\n    }\n}\n\nextension FragmentationTests {\n    /// Thread-safe delegate that supports awaiting message delivery\n    private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable {\n        private let lock = NSLock()\n        private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []\n        private var _receivedMessages: [BitchatMessage] = []\n        private var publicMessageContinuation: CheckedContinuation<Void, Never>?\n        private var receivedMessageContinuation: CheckedContinuation<Void, Never>?\n        private var expectedPublicMessageCount: Int = 0\n        private var expectedReceivedMessageCount: Int = 0\n\n        private func withLock<T>(_ body: () -> T) -> T {\n            lock.lock()\n            defer { lock.unlock() }\n            return body()\n        }\n\n        var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {\n            withLock { _publicMessages }\n        }\n\n        var receivedMessages: [BitchatMessage] {\n            withLock { _receivedMessages }\n        }\n\n        func didReceiveMessage(_ message: BitchatMessage) {\n            lock.lock()\n            _receivedMessages.append(message)\n            let count = _receivedMessages.count\n            let expected = expectedReceivedMessageCount\n            let continuation = receivedMessageContinuation\n            lock.unlock()\n\n            if count >= expected, let cont = continuation {\n                lock.lock()\n                receivedMessageContinuation = nil\n                lock.unlock()\n                cont.resume()\n            }\n        }\n\n        func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {\n            lock.lock()\n            _publicMessages.append((peerID, nickname, content))\n            let count = _publicMessages.count\n            let expected = expectedPublicMessageCount\n            let continuation = publicMessageContinuation\n            lock.unlock()\n\n            if count >= expected, let cont = continuation {\n                lock.lock()\n                publicMessageContinuation = nil\n                lock.unlock()\n                cont.resume()\n            }\n        }\n\n        /// Waits for the specified number of public messages to be received\n        func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {\n            let isAlreadySatisfied = withLock { () -> Bool in\n                if _publicMessages.count >= count {\n                    return true\n                }\n                expectedPublicMessageCount = count\n                return false\n            }\n            if isAlreadySatisfied {\n                return\n            }\n\n            try await withThrowingTaskGroup(of: Void.self) { group in\n                group.addTask {\n                    await withCheckedContinuation { continuation in\n                        let shouldResumeImmediately = self.withLock {\n                            // Recheck count after acquiring lock to avoid race condition\n                            // where message arrives between initial check and continuation install\n                            if self._publicMessages.count >= count {\n                                return true\n                            }\n                            self.publicMessageContinuation = continuation\n                            return false\n                        }\n                        if shouldResumeImmediately {\n                            continuation.resume()\n                        }\n                    }\n                }\n                group.addTask {\n                    try await Task.sleep(for: timeout)\n                    throw CancellationError()\n                }\n                try await group.next()\n                group.cancelAll()\n            }\n        }\n\n        /// Waits for the specified number of received messages\n        func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {\n            let isAlreadySatisfied = withLock { () -> Bool in\n                if _receivedMessages.count >= count {\n                    return true\n                }\n                expectedReceivedMessageCount = count\n                return false\n            }\n            if isAlreadySatisfied {\n                return\n            }\n\n            try await withThrowingTaskGroup(of: Void.self) { group in\n                group.addTask {\n                    await withCheckedContinuation { continuation in\n                        let shouldResumeImmediately = self.withLock {\n                            // Recheck count after acquiring lock to avoid race condition\n                            // where message arrives between initial check and continuation install\n                            if self._receivedMessages.count >= count {\n                                return true\n                            }\n                            self.receivedMessageContinuation = continuation\n                            return false\n                        }\n                        if shouldResumeImmediately {\n                            continuation.resume()\n                        }\n                    }\n                }\n                group.addTask {\n                    try await Task.sleep(for: timeout)\n                    throw CancellationError()\n                }\n                try await group.next()\n                group.cancelAll()\n            }\n        }\n\n        func didConnectToPeer(_ peerID: PeerID) {}\n        func didDisconnectFromPeer(_ peerID: PeerID) {}\n        func didUpdatePeerList(_ peers: [PeerID]) {}\n        func isFavorite(fingerprint: String) -> Bool { false }\n        func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}\n        func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}\n        func didUpdateBluetoothState(_ state: CBManagerState) {}\n        func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}\n    }\n\n    // Helper: build a large message packet (unencrypted public message)\n    private func makeLargePublicPacket(senderShortHex: PeerID, size: Int) -> BitchatPacket {\n        let content = String(repeating: \"A\", count: size)\n        let payload = Data(content.utf8)\n        let pkt = BitchatPacket(\n            type: MessageType.message.rawValue,\n            senderID: Data(hexString: senderShortHex.id) ?? Data(),\n            recipientID: nil,\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: payload,\n            signature: nil,\n            ttl: 7\n        )\n        return pkt\n    }\n\n    // Helper: fragment a packet using the same header format BLEService expects\n    private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] {\n        guard let fullData = packet.toBinaryData(padding: pad) else { return [] }\n        let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) })\n        let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in\n            Data(fullData[off..<min(off + fragmentSize, fullData.count)])\n        }\n        let total = UInt16(chunks.count)\n        var packets: [BitchatPacket] = []\n        for (i, chunk) in chunks.enumerated() {\n            var payload = Data()\n            payload.append(fid)\n            var idxBE = UInt16(i).bigEndian\n            var totBE = total.bigEndian\n            withUnsafeBytes(of: &idxBE) { payload.append(contentsOf: $0) }\n            withUnsafeBytes(of: &totBE) { payload.append(contentsOf: $0) }\n            payload.append(packet.type)\n            payload.append(chunk)\n            let fpkt = BitchatPacket(\n                type: MessageType.fragment.rawValue,\n                senderID: packet.senderID,\n                recipientID: packet.recipientID,\n                timestamp: packet.timestamp,\n                payload: payload,\n                signature: nil,\n                ttl: packet.ttl\n            )\n            packets.append(fpkt)\n        }\n        return packets\n    }\n}\n"
  },
  {
    "path": "bitchatTests/GCSFilterTests.swift",
    "content": "import Testing\nimport struct Foundation.Data\n@testable import bitchat\n\nstruct GCSFilterTests {\n    @Test func buildFilterWithDuplicateIdsProducesStableEncoding() {\n        let id = Data(repeating: 0xAB, count: 16)\n        let ids = Array(repeating: id, count: 64)\n\n        let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)\n        #expect(params.m >= 1)\n\n        let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)\n        #expect(decoded.count <= 1)\n    }\n\n    @Test func bucketAvoidsZeroCandidate() {\n        let id = Data(repeating: 0x01, count: 16)\n        let bucket = GCSFilter.bucket(for: id, modulus: 2)\n        #expect(bucket != 0)\n        #expect(bucket < 2)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/GeohashBookmarksStoreTests.swift",
    "content": "import Testing\nimport Foundation\n@testable import bitchat\n\nstruct GeohashBookmarksStoreTests {\n    private let storeKey = \"locationChannel.bookmarks\"\n    private let storage = UserDefaults(suiteName: UUID().uuidString)!\n    private let store: GeohashBookmarksStore\n\n    init() {\n        store = GeohashBookmarksStore(storage: storage)\n    }\n\n    @Test func toggleAndNormalize() {\n        // Start clean\n        #expect(store.bookmarks.isEmpty)\n\n        // Add with mixed case and hash prefix\n        store.toggle(\"#U4PRUY\")\n        #expect(store.isBookmarked(\"u4pruy\"))\n        #expect(store.bookmarks.first == \"u4pruy\")\n\n        // Toggling again removes\n        store.toggle(\"u4pruy\")\n        #expect(!store.isBookmarked(\"u4pruy\"))\n        #expect(store.bookmarks.isEmpty)\n    }\n\n    @Test func persistenceWritten() throws {\n        store.toggle(\"ezs42\")\n        store.toggle(\"u4pruy\")\n        // Verify persisted JSON contains both (order not enforced here)\n        let data = try #require(storage.data(forKey: storeKey), \"No persisted data found\")\n        let arr = try JSONDecoder().decode([String].self, from: data)\n        #expect(arr.contains(\"ezs42\"))\n        #expect(arr.contains(\"u4pruy\"))\n    }\n}\n"
  },
  {
    "path": "bitchatTests/GeohashParticipantTrackerTests.swift",
    "content": "//\n// GeohashParticipantTrackerTests.swift\n// bitchatTests\n//\n// Tests for GeohashParticipantTracker.\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\n/// Mock context for testing\n@MainActor\nfinal class MockParticipantContext: GeohashParticipantContext {\n    var blockedPubkeys: Set<String> = []\n    var nicknameMap: [String: String] = [:]\n    var selfPubkey: String?\n\n    func displayNameForPubkey(_ pubkeyHex: String) -> String {\n        let suffix = String(pubkeyHex.suffix(4))\n        if let self = selfPubkey, pubkeyHex.lowercased() == self.lowercased() {\n            return \"me#\\(suffix)\"\n        }\n        if let nick = nicknameMap[pubkeyHex.lowercased()] {\n            return \"\\(nick)#\\(suffix)\"\n        }\n        return \"anon#\\(suffix)\"\n    }\n\n    func isBlocked(_ pubkeyHexLowercased: String) -> Bool {\n        blockedPubkeys.contains(pubkeyHexLowercased.lowercased())\n    }\n}\n\n@MainActor\nstruct GeohashParticipantTrackerTests {\n\n    // MARK: - Basic Recording Tests\n\n    @Test func recordParticipant_addsToActiveGeohash() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n        tracker.setActiveGeohash(\"abc123\")\n\n        tracker.recordParticipant(pubkeyHex: \"deadbeef1234\")\n\n        #expect(tracker.participantCount(for: \"abc123\") == 1)\n    }\n\n    @Test func recordParticipant_noActiveGeohash_noOp() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n        // No active geohash set\n\n        tracker.recordParticipant(pubkeyHex: \"deadbeef1234\")\n\n        // Should not throw or crash\n        #expect(tracker.participantCount(for: \"abc123\") == 0)\n    }\n\n    @Test func recordParticipant_specificGeohash() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\", geohash: \"geo1\")\n        tracker.recordParticipant(pubkeyHex: \"pubkey2\", geohash: \"geo2\")\n\n        #expect(tracker.participantCount(for: \"geo1\") == 1)\n        #expect(tracker.participantCount(for: \"geo2\") == 1)\n    }\n\n    @Test func recordParticipant_updatesLastSeen() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n        tracker.setActiveGeohash(\"abc123\")\n\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\")\n        // Small delay and record again\n        try? await Task.sleep(nanoseconds: 10_000_000) // 10ms\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\")\n\n        // Should still count as 1 participant (updated, not duplicated)\n        #expect(tracker.participantCount(for: \"abc123\") == 1)\n    }\n\n    @Test func recordParticipant_lowercasesPubkey() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n        tracker.setActiveGeohash(\"abc123\")\n\n        tracker.recordParticipant(pubkeyHex: \"DEADBEEF\")\n        tracker.recordParticipant(pubkeyHex: \"deadbeef\")\n\n        // Should be treated as same participant\n        #expect(tracker.participantCount(for: \"abc123\") == 1)\n    }\n\n    // MARK: - Visible People Tests\n\n    @Test func getVisiblePeople_returnsActiveGeohashParticipants() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n        tracker.setActiveGeohash(\"abc123\")\n\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\")\n        tracker.recordParticipant(pubkeyHex: \"pubkey2\")\n\n        let people = tracker.getVisiblePeople()\n        #expect(people.count == 2)\n    }\n\n    @Test func getVisiblePeople_excludesBlockedParticipants() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        context.blockedPubkeys = [\"pubkey2\"]\n        tracker.configure(context: context)\n        tracker.setActiveGeohash(\"abc123\")\n\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\")\n        tracker.recordParticipant(pubkeyHex: \"pubkey2\")\n\n        let people = tracker.getVisiblePeople()\n        #expect(people.count == 1)\n        #expect(people.first?.id == \"pubkey1\")\n    }\n\n    @Test func getVisiblePeople_usesDisplayNameFromContext() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        context.nicknameMap = [\"pubkey1234\": \"alice\"]\n        tracker.configure(context: context)\n        tracker.setActiveGeohash(\"abc123\")\n\n        tracker.recordParticipant(pubkeyHex: \"pubkey1234\")\n\n        let people = tracker.getVisiblePeople()\n        #expect(people.count == 1)\n        #expect(people.first?.displayName == \"alice#1234\")\n    }\n\n    @Test func getVisiblePeople_sortedByLastSeen() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n        tracker.setActiveGeohash(\"abc123\")\n\n        tracker.recordParticipant(pubkeyHex: \"older\")\n        try? await Task.sleep(nanoseconds: 10_000_000) // 10ms\n        tracker.recordParticipant(pubkeyHex: \"newer\")\n\n        let people = tracker.getVisiblePeople()\n        #expect(people.count == 2)\n        #expect(people.first?.id == \"newer\")\n        #expect(people.last?.id == \"older\")\n    }\n\n    @Test func getVisiblePeople_emptyWhenNoActiveGeohash() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\", geohash: \"abc123\")\n\n        let people = tracker.getVisiblePeople()\n        #expect(people.isEmpty)\n    }\n\n    // MARK: - Activity Cutoff Tests\n\n    @Test func participantCount_excludesExpiredEntries() async {\n        // Use a very short cutoff for testing\n        let tracker = GeohashParticipantTracker(activityCutoff: -0.05) // 50ms cutoff\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n        tracker.setActiveGeohash(\"abc123\")\n\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\")\n\n        // Should be counted immediately\n        #expect(tracker.participantCount(for: \"abc123\") == 1)\n\n        // Wait for expiry\n        try? await Task.sleep(nanoseconds: 100_000_000) // 100ms\n\n        // Should be expired now\n        #expect(tracker.participantCount(for: \"abc123\") == 0)\n    }\n\n    // MARK: - Remove Participant Tests\n\n    @Test func removeParticipant_removesFromAllGeohashes() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\", geohash: \"geo1\")\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\", geohash: \"geo2\")\n        tracker.recordParticipant(pubkeyHex: \"pubkey2\", geohash: \"geo1\")\n\n        tracker.removeParticipant(pubkeyHex: \"pubkey1\")\n\n        #expect(tracker.participantCount(for: \"geo1\") == 1)\n        #expect(tracker.participantCount(for: \"geo2\") == 0)\n    }\n\n    // MARK: - Clear Tests\n\n    @Test func clear_removesAllData() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n        tracker.setActiveGeohash(\"abc123\")\n\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\")\n        tracker.recordParticipant(pubkeyHex: \"pubkey2\", geohash: \"other\")\n\n        tracker.clear()\n\n        #expect(tracker.participantCount(for: \"abc123\") == 0)\n        #expect(tracker.participantCount(for: \"other\") == 0)\n        #expect(tracker.visiblePeople.isEmpty)\n    }\n\n    @Test func clearGeohash_removesOnlySpecificGeohash() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\", geohash: \"geo1\")\n        tracker.recordParticipant(pubkeyHex: \"pubkey2\", geohash: \"geo2\")\n\n        tracker.clear(geohash: \"geo1\")\n\n        #expect(tracker.participantCount(for: \"geo1\") == 0)\n        #expect(tracker.participantCount(for: \"geo2\") == 1)\n    }\n\n    // MARK: - Set Active Geohash Tests\n\n    @Test func setActiveGeohash_clearsVisiblePeopleWhenNil() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n        tracker.setActiveGeohash(\"abc123\")\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\")\n\n        #expect(!tracker.visiblePeople.isEmpty)\n\n        tracker.setActiveGeohash(nil)\n\n        #expect(tracker.visiblePeople.isEmpty)\n    }\n\n    @Test func setActiveGeohash_refreshesVisiblePeople() async {\n        let tracker = GeohashParticipantTracker()\n        let context = MockParticipantContext()\n        tracker.configure(context: context)\n\n        // Pre-populate a geohash\n        tracker.recordParticipant(pubkeyHex: \"pubkey1\", geohash: \"abc123\")\n\n        // Set it as active\n        tracker.setActiveGeohash(\"abc123\")\n\n        #expect(tracker.visiblePeople.count == 1)\n    }\n\n    // MARK: - GeoPerson Tests\n\n    @Test func geoPerson_identifiable() async {\n        let person1 = GeoPerson(id: \"abc\", displayName: \"alice\", lastSeen: Date())\n        let person2 = GeoPerson(id: \"abc\", displayName: \"alice\", lastSeen: Date())\n        let person3 = GeoPerson(id: \"xyz\", displayName: \"bob\", lastSeen: Date())\n\n        #expect(person1.id == person2.id)\n        #expect(person1.id != person3.id)\n    }\n\n    @Test func geoPerson_equatable() async {\n        let date = Date()\n        let person1 = GeoPerson(id: \"abc\", displayName: \"alice\", lastSeen: date)\n        let person2 = GeoPerson(id: \"abc\", displayName: \"alice\", lastSeen: date)\n\n        #expect(person1 == person2)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/GeohashPresenceTests.swift",
    "content": "//\n// GeohashPresenceTests.swift\n// bitchatTests\n//\n// Tests for the Geohash Presence (Kind 20001) feature.\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Testing\nimport Foundation\nimport Combine\n@testable import bitchat\n\n// MARK: - NostrProtocol Presence Event Tests\n\nstruct NostrProtocolPresenceTests {\n\n    @Test func createGeohashPresenceEvent_hasCorrectKind() throws {\n        let identity = try makeTestIdentity()\n        let event = try NostrProtocol.createGeohashPresenceEvent(\n            geohash: \"u4pruydq\",\n            senderIdentity: identity\n        )\n\n        #expect(event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)\n        #expect(event.kind == 20001)\n    }\n\n    @Test func createGeohashPresenceEvent_hasEmptyContent() throws {\n        let identity = try makeTestIdentity()\n        let event = try NostrProtocol.createGeohashPresenceEvent(\n            geohash: \"u4pruydq\",\n            senderIdentity: identity\n        )\n\n        #expect(event.content == \"\")\n    }\n\n    @Test func createGeohashPresenceEvent_hasOnlyGeohashTag() throws {\n        let identity = try makeTestIdentity()\n        let event = try NostrProtocol.createGeohashPresenceEvent(\n            geohash: \"u4pruydq\",\n            senderIdentity: identity\n        )\n\n        // Should have exactly one tag: [\"g\", geohash]\n        #expect(event.tags.count == 1)\n        #expect(event.tags[0] == [\"g\", \"u4pruydq\"])\n    }\n\n    @Test func createGeohashPresenceEvent_noNicknameTag() throws {\n        let identity = try makeTestIdentity()\n        let event = try NostrProtocol.createGeohashPresenceEvent(\n            geohash: \"u4pruydq\",\n            senderIdentity: identity\n        )\n\n        // Should NOT contain nickname tag\n        let hasNicknameTag = event.tags.contains { $0.first == \"n\" }\n        #expect(!hasNicknameTag)\n    }\n\n    @Test func createGeohashPresenceEvent_usesSenderPubkey() throws {\n        let identity = try makeTestIdentity()\n        let event = try NostrProtocol.createGeohashPresenceEvent(\n            geohash: \"u4pruydq\",\n            senderIdentity: identity\n        )\n\n        #expect(event.pubkey == identity.publicKeyHex)\n    }\n\n    @Test func createGeohashPresenceEvent_isSigned() throws {\n        let identity = try makeTestIdentity()\n        let event = try NostrProtocol.createGeohashPresenceEvent(\n            geohash: \"u4pruydq\",\n            senderIdentity: identity\n        )\n\n        #expect(event.sig != nil && !event.sig!.isEmpty)\n        #expect(!event.id.isEmpty)\n    }\n\n    @Test func createGeohashPresenceEvent_differentGeohashes() throws {\n        let identity = try makeTestIdentity()\n\n        let event1 = try NostrProtocol.createGeohashPresenceEvent(geohash: \"87\", senderIdentity: identity)\n        let event2 = try NostrProtocol.createGeohashPresenceEvent(geohash: \"87yw\", senderIdentity: identity)\n        let event3 = try NostrProtocol.createGeohashPresenceEvent(geohash: \"87yw7\", senderIdentity: identity)\n\n        #expect(event1.tags[0][1] == \"87\")\n        #expect(event2.tags[0][1] == \"87yw\")\n        #expect(event3.tags[0][1] == \"87yw7\")\n    }\n\n    // MARK: - Helper\n\n    private func makeTestIdentity() throws -> NostrIdentity {\n        // Generate a fresh test identity\n        return try NostrIdentity.generate()\n    }\n}\n\n// MARK: - NostrFilter Presence Tests\n\nstruct NostrFilterPresenceTests {\n\n    @Test func geohashEphemeral_includesBothKinds() {\n        let filter = NostrFilter.geohashEphemeral(\"u4pruydq\")\n\n        #expect(filter.kinds?.contains(20000) == true)\n        #expect(filter.kinds?.contains(20001) == true)\n    }\n\n    @Test func geohashEphemeral_hasLimit1000() {\n        let filter = NostrFilter.geohashEphemeral(\"u4pruydq\")\n\n        #expect(filter.limit == 1000)\n    }\n\n    @Test func geohashEphemeral_respectsSinceParameter() {\n        let since = Date(timeIntervalSince1970: 1700000000)\n        let filter = NostrFilter.geohashEphemeral(\"u4pruydq\", since: since)\n\n        #expect(filter.since == 1700000000)\n    }\n}\n\n// MARK: - ChatViewModel Presence Handling Tests\n\n@MainActor\nstruct ChatViewModelPresenceHandlingTests {\n\n    @Test func handleNostrEvent_presenceUpdatesParticipantTracker() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n\n        // Set up the channel\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n\n        // Create a presence event (kind 20001)\n        let identity = try NostrIdentity.generate()\n        let event = NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .geohashPresence,\n            tags: [[\"g\", geohash]],\n            content: \"\"\n        )\n        let signed = try event.sign(with: identity.schnorrSigningKey())\n\n        // Handle the event\n        viewModel.handleNostrEvent(signed)\n\n        // Allow async processing\n        try? await Task.sleep(nanoseconds: 50_000_000)\n\n        // Participant should be recorded\n        let count = viewModel.geohashParticipantCount(for: geohash)\n        #expect(count >= 1)\n    }\n\n    @Test func handleNostrEvent_presenceDoesNotAddToTimeline() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n\n        let initialMessageCount = viewModel.messages.count\n\n        // Create a presence event (kind 20001)\n        let identity = try NostrIdentity.generate()\n        let event = NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .geohashPresence,\n            tags: [[\"g\", geohash]],\n            content: \"\"\n        )\n        let signed = try event.sign(with: identity.schnorrSigningKey())\n\n        viewModel.handleNostrEvent(signed)\n\n        try? await Task.sleep(nanoseconds: 50_000_000)\n\n        // Message count should NOT increase\n        #expect(viewModel.messages.count == initialMessageCount)\n    }\n\n    @Test func handleNostrEvent_chatMessageUpdatesParticipant() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n\n        // Create a chat event (kind 20000) - NOT presence\n        let identity = try NostrIdentity.generate()\n        let event = NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .ephemeralEvent,\n            tags: [[\"g\", geohash]],\n            content: \"Hello world\"\n        )\n        let signed = try event.sign(with: identity.schnorrSigningKey())\n\n        viewModel.handleNostrEvent(signed)\n\n        try? await Task.sleep(nanoseconds: 50_000_000)\n\n        // Chat messages should also update participant count (not just presence)\n        let count = viewModel.geohashParticipantCount(for: geohash)\n        #expect(count >= 1)\n    }\n\n    @Test func presenceEvent_hasDifferentKindThanChat() {\n        // Verify the two event kinds are distinct\n        let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue\n        let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue\n\n        #expect(presenceKind != chatKind)\n        #expect(presenceKind == 20001)\n        #expect(chatKind == 20000)\n    }\n\n    @Test func subscribeNostrEvent_acceptsPresenceKind() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let geohash = \"u4pruydq\"\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))\n\n        // Create presence event\n        let identity = try NostrIdentity.generate()\n        let event = NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .geohashPresence,\n            tags: [[\"g\", geohash]],\n            content: \"\"\n        )\n        let signed = try event.sign(with: identity.schnorrSigningKey())\n\n        // subscribeNostrEvent should accept kind 20001\n        viewModel.subscribeNostrEvent(signed)\n\n        try? await Task.sleep(nanoseconds: 50_000_000)\n\n        // Should record participant\n        let count = viewModel.geohashParticipantCount(for: geohash)\n        #expect(count >= 1)\n    }\n\n    @Test func subscribeNostrEvent_presenceForNonActiveGeohash() async throws {\n        let (viewModel, _) = makeTestableViewModel()\n        let activeGeohash = \"u4pruydq\"\n        let otherGeohash = \"87yw7\"\n\n        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))\n\n        // Create presence event for a DIFFERENT geohash\n        let identity = try NostrIdentity.generate()\n        let event = NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .geohashPresence,\n            tags: [[\"g\", otherGeohash]],\n            content: \"\"\n        )\n        let signed = try event.sign(with: identity.schnorrSigningKey())\n\n        // Use subscribeNostrEvent with geohash parameter\n        viewModel.subscribeNostrEvent(signed, gh: otherGeohash)\n\n        try? await Task.sleep(nanoseconds: 50_000_000)\n\n        // Should record for the other geohash\n        let count = viewModel.geohashParticipantCount(for: otherGeohash)\n        #expect(count >= 1)\n    }\n\n    // MARK: - Test Helper\n\n    private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {\n        let keychain = MockKeychain()\n        let keychainHelper = MockKeychainHelper()\n        let idBridge = NostrIdentityBridge(keychain: keychainHelper)\n        let identityManager = MockIdentityManager(keychain)\n        let transport = MockTransport()\n\n        let viewModel = ChatViewModel(\n            keychain: keychain,\n            idBridge: idBridge,\n            identityManager: identityManager,\n            transport: transport\n        )\n\n        return (viewModel, transport)\n    }\n}\n\n// MARK: - Presence Privacy Tests\n\nstruct GeohashPresencePrivacyTests {\n\n    @Test func allowedPrecisions_onlyLowPrecision() {\n        // The allowed precisions for presence broadcasting should be:\n        // Region (2), Province (4), City (5)\n        // NOT Neighborhood (6), Block (7), Building (8+)\n\n        let regionPrecision = GeohashChannelLevel.region.precision\n        let provincePrecision = GeohashChannelLevel.province.precision\n        let cityPrecision = GeohashChannelLevel.city.precision\n        let neighborhoodPrecision = GeohashChannelLevel.neighborhood.precision\n        let blockPrecision = GeohashChannelLevel.block.precision\n        let buildingPrecision = GeohashChannelLevel.building.precision\n\n        #expect(regionPrecision == 2)\n        #expect(provincePrecision == 4)\n        #expect(cityPrecision == 5)\n        #expect(neighborhoodPrecision == 6)\n        #expect(blockPrecision == 7)\n        #expect(buildingPrecision == 8)\n\n        // High precision channels should NOT receive presence broadcasts\n        #expect(neighborhoodPrecision > 5)\n        #expect(blockPrecision > 5)\n        #expect(buildingPrecision > 5)\n    }\n\n    @Test func geohashLengthDeterminesPrecision() {\n        // Verify geohash length maps to expected precision\n        #expect(\"87\".count == GeohashChannelLevel.region.precision)\n        #expect(\"87yw\".count == GeohashChannelLevel.province.precision)\n        #expect(\"87yw7\".count == GeohashChannelLevel.city.precision)\n        #expect(\"87yw7t\".count == GeohashChannelLevel.neighborhood.precision)\n        #expect(\"87yw7tc\".count == GeohashChannelLevel.block.precision)\n        #expect(\"87yw7tcx\".count == GeohashChannelLevel.building.precision)\n    }\n\n    @Test func highPrecisionGeohash_isPrivacySensitive() {\n        // Helper to check if a geohash is \"high precision\" (privacy sensitive)\n        func isHighPrecision(_ geohash: String) -> Bool {\n            geohash.count >= 6\n        }\n\n        // Low precision - OK to broadcast presence\n        #expect(!isHighPrecision(\"87\"))      // region\n        #expect(!isHighPrecision(\"87yw\"))    // province\n        #expect(!isHighPrecision(\"87yw7\"))   // city\n\n        // High precision - should NOT broadcast presence\n        #expect(isHighPrecision(\"87yw7t\"))   // neighborhood\n        #expect(isHighPrecision(\"87yw7tc\"))  // block\n        #expect(isHighPrecision(\"87yw7tcx\")) // building\n    }\n}\n\n// MARK: - Display Logic Tests\n\nstruct LocationChannelsDisplayLogicTests {\n\n    @Test func displayLogic_highPrecisionZeroCount_showsUnknown() {\n        // Test the logic that determines \"?\" vs actual count\n        // High precision + count 0 = \"?\"\n\n        let shouldShowUnknown = shouldShowUnknownCount(\n            level: .neighborhood,\n            count: 0\n        )\n        #expect(shouldShowUnknown)\n    }\n\n    @Test func displayLogic_highPrecisionNonZeroCount_showsActual() {\n        // High precision + count > 0 = show actual\n        let shouldShowUnknown = shouldShowUnknownCount(\n            level: .neighborhood,\n            count: 5\n        )\n        #expect(!shouldShowUnknown)\n    }\n\n    @Test func displayLogic_lowPrecisionZeroCount_showsActual() {\n        // Low precision + count 0 = show \"0\" (not \"?\")\n        let shouldShowUnknown = shouldShowUnknownCount(\n            level: .city,\n            count: 0\n        )\n        #expect(!shouldShowUnknown)\n    }\n\n    @Test func displayLogic_lowPrecisionNonZeroCount_showsActual() {\n        // Low precision + count > 0 = show actual\n        let shouldShowUnknown = shouldShowUnknownCount(\n            level: .region,\n            count: 10\n        )\n        #expect(!shouldShowUnknown)\n    }\n\n    @Test func displayLogic_allHighPrecisionLevels() {\n        // All high precision levels with 0 should show \"?\"\n        let highPrecisionLevels: [GeohashChannelLevel] = [.neighborhood, .block, .building]\n\n        for level in highPrecisionLevels {\n            let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0)\n            #expect(shouldShowUnknown, \"Level \\(level) with count 0 should show unknown\")\n        }\n    }\n\n    @Test func displayLogic_allLowPrecisionLevels() {\n        // All low precision levels with 0 should show actual count\n        let lowPrecisionLevels: [GeohashChannelLevel] = [.region, .province, .city]\n\n        for level in lowPrecisionLevels {\n            let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0)\n            #expect(!shouldShowUnknown, \"Level \\(level) with count 0 should show actual count\")\n        }\n    }\n\n    @Test func displayLogic_bookmarkHighPrecision() {\n        // Bookmarks use geohash length to determine precision\n        #expect(shouldShowUnknownForBookmark(geohash: \"87yw7t\", count: 0))   // len 6\n        #expect(shouldShowUnknownForBookmark(geohash: \"87yw7tc\", count: 0))  // len 7\n        #expect(shouldShowUnknownForBookmark(geohash: \"87yw7tcx\", count: 0)) // len 8\n    }\n\n    @Test func displayLogic_bookmarkLowPrecision() {\n        #expect(!shouldShowUnknownForBookmark(geohash: \"87\", count: 0))     // len 2\n        #expect(!shouldShowUnknownForBookmark(geohash: \"87yw\", count: 0))   // len 4\n        #expect(!shouldShowUnknownForBookmark(geohash: \"87yw7\", count: 0))  // len 5\n    }\n\n    // MARK: - Helpers (mirror the logic from LocationChannelsSheet)\n\n    private func shouldShowUnknownCount(level: GeohashChannelLevel, count: Int) -> Bool {\n        let isHighPrecision = (level == .neighborhood || level == .block || level == .building)\n        return isHighPrecision && count == 0\n    }\n\n    private func shouldShowUnknownForBookmark(geohash: String, count: Int) -> Bool {\n        let isHighPrecision = (geohash.count >= 6)\n        return isHighPrecision && count == 0\n    }\n}\n\n// MARK: - Event Kind Tests\n\nstruct NostrEventKindTests {\n\n    @Test func eventKind_geohashPresence_is20001() {\n        #expect(NostrProtocol.EventKind.geohashPresence.rawValue == 20001)\n    }\n\n    @Test func eventKind_ephemeralEvent_is20000() {\n        #expect(NostrProtocol.EventKind.ephemeralEvent.rawValue == 20000)\n    }\n\n    @Test func eventKind_presenceIsEphemeral() {\n        // Both 20000 and 20001 are in the ephemeral range (20000-29999)\n        let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue\n        let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue\n\n        #expect(presenceKind >= 20000 && presenceKind < 30000)\n        #expect(chatKind >= 20000 && chatKind < 30000)\n    }\n}\n\n// MARK: - Participant Tracker Presence Integration Tests\n\n@MainActor\nstruct ParticipantTrackerPresenceTests {\n\n    @Test func recordParticipant_fromPresenceEvent_countsParticipant() async {\n        let tracker = GeohashParticipantTracker()\n        let context = PresenceTestParticipantContext()\n        tracker.configure(context: context)\n\n        let geohash = \"87yw7\"\n        tracker.setActiveGeohash(geohash)\n\n        // Simulate recording from a presence event\n        tracker.recordParticipant(pubkeyHex: \"presence_user_1\")\n\n        #expect(tracker.participantCount(for: geohash) == 1)\n    }\n\n    @Test func recordParticipant_multiplePresenceEvents_countsUnique() async {\n        let tracker = GeohashParticipantTracker()\n        let context = PresenceTestParticipantContext()\n        tracker.configure(context: context)\n\n        let geohash = \"87yw7\"\n        tracker.setActiveGeohash(geohash)\n\n        // Multiple presence events from same user = 1 participant\n        tracker.recordParticipant(pubkeyHex: \"user_a\")\n        tracker.recordParticipant(pubkeyHex: \"user_a\")\n        tracker.recordParticipant(pubkeyHex: \"user_a\")\n\n        #expect(tracker.participantCount(for: geohash) == 1)\n\n        // Different user = 2 participants\n        tracker.recordParticipant(pubkeyHex: \"user_b\")\n\n        #expect(tracker.participantCount(for: geohash) == 2)\n    }\n\n    @Test func recordParticipant_nonActiveGeohash_stillCounts() async {\n        let tracker = GeohashParticipantTracker()\n        let context = PresenceTestParticipantContext()\n        tracker.configure(context: context)\n\n        // Active geohash is different from where we're recording\n        tracker.setActiveGeohash(\"active_gh\")\n\n        // Record to a non-active geohash (like when sampling nearby channels)\n        tracker.recordParticipant(pubkeyHex: \"nearby_user\", geohash: \"other_gh\")\n\n        #expect(tracker.participantCount(for: \"other_gh\") == 1)\n        #expect(tracker.participantCount(for: \"active_gh\") == 0)\n    }\n\n    @Test func objectWillChange_firesOnNonActiveGeohashUpdate() async {\n        let tracker = GeohashParticipantTracker()\n        let context = PresenceTestParticipantContext()\n        tracker.configure(context: context)\n\n        tracker.setActiveGeohash(\"active_gh\")\n\n        var changeCount = 0\n        let cancellable = tracker.objectWillChange.sink { _ in\n            changeCount += 1\n        }\n\n        // Record to non-active geohash\n        tracker.recordParticipant(pubkeyHex: \"user1\", geohash: \"other_gh\")\n\n        // Should fire objectWillChange even for non-active geohash\n        #expect(changeCount >= 1)\n\n        _ = cancellable // Keep alive\n    }\n}\n\n// MARK: - Mock for Participant Context (Presence Tests)\n\n@MainActor\nprivate final class PresenceTestParticipantContext: GeohashParticipantContext {\n    var blockedPubkeys: Set<String> = []\n    var nicknameMap: [String: String] = [:]\n    var selfPubkey: String?\n\n    func displayNameForPubkey(_ pubkeyHex: String) -> String {\n        let suffix = String(pubkeyHex.suffix(4))\n        if let s = selfPubkey, pubkeyHex.lowercased() == s.lowercased() {\n            return \"me#\\(suffix)\"\n        }\n        if let nick = nicknameMap[pubkeyHex.lowercased()] {\n            return \"\\(nick)#\\(suffix)\"\n        }\n        return \"anon#\\(suffix)\"\n    }\n\n    func isBlocked(_ pubkeyHexLowercased: String) -> Bool {\n        blockedPubkeys.contains(pubkeyHexLowercased.lowercased())\n    }\n}\n"
  },
  {
    "path": "bitchatTests/GossipSyncManagerTests.swift",
    "content": "import Foundation\nimport Testing\n@testable import bitchat\n\nstruct GossipSyncManagerTests {\n\n    private let myPeerID = PeerID(str: \"0102030405060708\")\n    \n    @Test func concurrentPacketIntakeAndSyncRequest() async throws {\n        let requestSyncManager = RequestSyncManager()\n        let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager)\n        let delegate = RecordingDelegate()\n        manager.delegate = delegate\n\n        try await confirmation(\"sync request sent\") { sent in\n            delegate.onSend = {\n                delegate.onSend = nil\n                sent()\n            }\n\n            let iterations = 200\n            let senderID = try #require(Data(hexString: \"1122334455667788\"))\n            \n            for i in 0..<iterations {\n                let packet = BitchatPacket(\n                    type: MessageType.message.rawValue,\n                    senderID: senderID,\n                    recipientID: nil,\n                    timestamp: 1_000_000 + UInt64(i),\n                    payload: Data([UInt8(truncatingIfNeeded: i)]),\n                    signature: nil,\n                    ttl: 1\n                )\n                manager.onPublicPacketSeen(packet)\n                try await sleep(0.001)\n            }\n\n            manager.scheduleInitialSyncToPeer(PeerID(str: \"FFFFFFFFFFFFFFFF\"), delaySeconds: 0.0)\n            try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout)\n        }\n\n        let lastPacket = try #require(delegate.lastPacket, \"Expected sync packet to be sent\")\n        #expect(lastPacket.type == MessageType.requestSync.rawValue)\n        #expect(RequestSyncPacket.decode(from: lastPacket.payload) != nil)\n    }\n\n    @Test func staleAnnouncementsArePurgedWithMessages() throws {\n        var config = GossipSyncManager.Config()\n        config.stalePeerCleanupIntervalSeconds = 0\n        config.stalePeerTimeoutSeconds = 5\n\n        let requestSyncManager = RequestSyncManager()\n        let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)\n        let peerHex = \"0011223344556677\"\n        let senderData = try #require(Data(hexString: peerHex))\n        let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)\n\n        let announcePacket = BitchatPacket(\n            type: MessageType.announce.rawValue,\n            senderID: senderData,\n            recipientID: nil,\n            timestamp: initialTimestampMs,\n            payload: Data(),\n            signature: nil,\n            ttl: 1\n        )\n\n        let messagePacket = BitchatPacket(\n            type: MessageType.message.rawValue,\n            senderID: senderData,\n            recipientID: nil,\n            timestamp: initialTimestampMs,\n            payload: Data([0x01]),\n            signature: nil,\n            ttl: 1\n        )\n\n        manager.onPublicPacketSeen(announcePacket)\n        manager.onPublicPacketSeen(messagePacket)\n\n        // Flush queue without triggering stale cleanup yet\n        manager._performMaintenanceSynchronously(now: Date())\n        #expect(manager._hasAnnouncement(for: PeerID(str: peerHex)))\n        #expect(manager._messageCount(for: PeerID(str: peerHex)) == 1)\n        \n        // Run cleanup past the timeout\n        let future = Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)\n        manager._performMaintenanceSynchronously(now: future)\n        #expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)\n        #expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)\n    }\n\n    @Test func ignoresAnnounceOlderThanStaleTimeout() throws {\n        var config = GossipSyncManager.Config()\n        config.stalePeerTimeoutSeconds = 5\n        config.maxMessageAgeSeconds = 100\n\n        let requestSyncManager = RequestSyncManager()\n        let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)\n        let peerHex = \"8899aabbccddeeff\"\n        let senderData = try #require(Data(hexString: peerHex))\n        let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)\n\n        let freshMessage = BitchatPacket(\n            type: MessageType.message.rawValue,\n            senderID: senderData,\n            recipientID: nil,\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: Data([0xAA]),\n            signature: nil,\n            ttl: 1\n        )\n        manager.onPublicPacketSeen(freshMessage)\n\n        let announcePacket = BitchatPacket(\n            type: MessageType.announce.rawValue,\n            senderID: senderData,\n            recipientID: nil,\n            timestamp: staleTimestampMs,\n            payload: Data(),\n            signature: nil,\n            ttl: 1\n        )\n\n        manager.onPublicPacketSeen(announcePacket)\n\n        manager._performMaintenanceSynchronously()\n\n        #expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)\n        #expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)\n    }\n\n    @Test func maintenanceEmitsTypedSyncRequests() throws {\n        var config = GossipSyncManager.Config()\n        config.seenCapacity = 10\n        config.fragmentCapacity = 5\n        config.fileTransferCapacity = 4\n        config.messageSyncIntervalSeconds = 1\n        config.fragmentSyncIntervalSeconds = 1\n        config.fileTransferSyncIntervalSeconds = 1\n        config.maintenanceIntervalSeconds = 0\n\n        let requestSyncManager = RequestSyncManager()\n        let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)\n        let delegate = RecordingDelegate()\n        manager.delegate = delegate\n\n        let sender = try #require(Data(hexString: \"1122334455667788\"))\n        let now = UInt64(Date().timeIntervalSince1970 * 1000)\n\n        let announcePacket = BitchatPacket(\n            type: MessageType.announce.rawValue,\n            senderID: sender,\n            recipientID: nil,\n            timestamp: now,\n            payload: Data(),\n            signature: nil,\n            ttl: 1\n        )\n        let messagePacket = BitchatPacket(\n            type: MessageType.message.rawValue,\n            senderID: sender,\n            recipientID: nil,\n            timestamp: now,\n            payload: Data([0x01]),\n            signature: nil,\n            ttl: 1\n        )\n        let fragmentPacket = BitchatPacket(\n            type: MessageType.fragment.rawValue,\n            senderID: sender,\n            recipientID: nil,\n            timestamp: now,\n            payload: Data([0xAA]),\n            signature: nil,\n            ttl: 1\n        )\n        let filePacket = BitchatPacket(\n            type: MessageType.fileTransfer.rawValue,\n            senderID: sender,\n            recipientID: nil,\n            timestamp: now,\n            payload: Data([0xBB]),\n            signature: nil,\n            ttl: 1,\n            version: 2\n        )\n\n        manager.onPublicPacketSeen(announcePacket)\n        manager.onPublicPacketSeen(messagePacket)\n        manager.onPublicPacketSeen(fragmentPacket)\n        manager.onPublicPacketSeen(filePacket)\n\n        manager._performMaintenanceSynchronously(now: Date())\n\n        let sentPackets = delegate.packets\n        #expect(sentPackets.count == 3)\n        let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }\n        #expect(decoded.count == 3)\n        #expect(decoded[0].types == .publicMessages)\n        #expect(decoded[1].types == .fragment)\n        #expect(decoded[2].types == .fileTransfer)\n    }\n\n    @Test func handleRequestSyncHonorsTypeFilter() async throws {\n        var config = GossipSyncManager.Config()\n        config.seenCapacity = 5\n        config.fragmentCapacity = 5\n        config.fileTransferCapacity = 0\n        config.messageSyncIntervalSeconds = 0\n        config.fragmentSyncIntervalSeconds = 0\n        config.fileTransferSyncIntervalSeconds = 0\n\n        let requestSyncManager = RequestSyncManager()\n        let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)\n        let delegate = RecordingDelegate()\n        manager.delegate = delegate\n\n        let sender = try #require(Data(hexString: \"aabbccddeeff0011\"))\n        let now = UInt64(Date().timeIntervalSince1970 * 1000)\n\n        let messagePacket = BitchatPacket(\n            type: MessageType.message.rawValue,\n            senderID: sender,\n            recipientID: nil,\n            timestamp: now,\n            payload: Data([0x10]),\n            signature: nil,\n            ttl: 1\n        )\n\n        let fragmentPacket = BitchatPacket(\n            type: MessageType.fragment.rawValue,\n            senderID: sender,\n            recipientID: nil,\n            timestamp: now,\n            payload: Data([0x20]),\n            signature: nil,\n            ttl: 1\n        )\n\n        manager.onPublicPacketSeen(messagePacket)\n        manager.onPublicPacketSeen(fragmentPacket)\n\n        let peer = PeerID(str: \"FFFFFFFFFFFFFFFF\")\n        let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)\n        manager.handleRequestSync(from: peer, request: request)\n\n        try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)\n        let sentPackets = delegate.packets\n        #expect(sentPackets.count == 1)\n        #expect(sentPackets[0].type == MessageType.fragment.rawValue)\n    }\n}\n\nprivate final class RecordingDelegate: GossipSyncManager.Delegate {\n    var onSend: (() -> Void)?\n    private(set) var lastPacket: BitchatPacket?\n    private(set) var packets: [BitchatPacket] = []\n    private let lock = NSLock()\n\n    func sendPacket(_ packet: BitchatPacket) {\n        lock.lock()\n        lastPacket = packet\n        packets.append(packet)\n        lock.unlock()\n        onSend?()\n    }\n\n    func sendPacket(to peerID: PeerID, packet: BitchatPacket) {\n        sendPacket(packet)\n    }\n\n    func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {\n        packet\n    }\n    \n    func getConnectedPeers() -> [PeerID] {\n        return []\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>CFBundleIdentifier</key>\n    <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n    <key>CFBundleInfoDictionaryVersion</key>\n    <string>6.0</string>\n    <key>CFBundleName</key>\n    <string>$(PRODUCT_NAME)</string>\n    <key>CFBundlePackageType</key>\n    <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>\n    <key>CFBundleShortVersionString</key>\n    <string>1.0</string>\n    <key>CFBundleVersion</key>\n    <string>1</string>\n</dict>\n</plist>"
  },
  {
    "path": "bitchatTests/InputValidatorTests.swift",
    "content": "//\n// InputValidatorTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\nstruct InputValidatorTests {\n\n    // MARK: - Basic Validation Tests\n\n    @Test func validStringPassesValidation() throws {\n        let result = InputValidator.validateUserString(\"Hello World\", maxLength: 100)\n        #expect(result == \"Hello World\")\n    }\n\n    @Test func emptyStringReturnsNil() throws {\n        let result = InputValidator.validateUserString(\"\", maxLength: 100)\n        #expect(result == nil)\n    }\n\n    @Test func whitespaceOnlyStringReturnsNil() throws {\n        let result = InputValidator.validateUserString(\"   \\n\\t  \", maxLength: 100)\n        #expect(result == nil)\n    }\n\n    @Test func stringExceedingMaxLengthReturnsNil() throws {\n        let longString = String(repeating: \"a\", count: 101)\n        let result = InputValidator.validateUserString(longString, maxLength: 100)\n        #expect(result == nil)\n    }\n\n    @Test func stringAtMaxLengthIsAccepted() throws {\n        let exactString = String(repeating: \"a\", count: 100)\n        let result = InputValidator.validateUserString(exactString, maxLength: 100)\n        #expect(result == exactString)\n    }\n\n    @Test func whitespaceIsTrimmed() throws {\n        let result = InputValidator.validateUserString(\"  Hello  \", maxLength: 100)\n        #expect(result == \"Hello\")\n    }\n\n    // MARK: - Control Character Tests\n\n    @Test func nullCharacterIsRejected() throws {\n        let stringWithNull = \"Hello\\u{0000}World\"\n        let result = InputValidator.validateUserString(stringWithNull, maxLength: 100)\n        #expect(result == nil)\n    }\n\n    @Test func bellCharacterIsRejected() throws {\n        let stringWithBell = \"Hello\\u{0007}World\"\n        let result = InputValidator.validateUserString(stringWithBell, maxLength: 100)\n        #expect(result == nil)\n    }\n\n    @Test func backspaceCharacterIsRejected() throws {\n        let stringWithBackspace = \"Hello\\u{0008}World\"\n        let result = InputValidator.validateUserString(stringWithBackspace, maxLength: 100)\n        #expect(result == nil)\n    }\n\n    @Test func escapeCharacterIsRejected() throws {\n        let stringWithEscape = \"Hello\\u{001B}World\"\n        let result = InputValidator.validateUserString(stringWithEscape, maxLength: 100)\n        #expect(result == nil)\n    }\n\n    @Test func deleteCharacterIsRejected() throws {\n        let stringWithDelete = \"Hello\\u{007F}World\"\n        let result = InputValidator.validateUserString(stringWithDelete, maxLength: 100)\n        #expect(result == nil)\n    }\n\n    @Test func multipleControlCharactersAreRejected() throws {\n        let stringWithMultiple = \"Hello\\u{0000}\\u{0007}\\u{001B}World\"\n        let result = InputValidator.validateUserString(stringWithMultiple, maxLength: 100)\n        #expect(result == nil)\n    }\n\n    // MARK: - Unicode and Special Character Tests\n\n    @Test func emojiIsAccepted() throws {\n        let result = InputValidator.validateUserString(\"Hello 👋 World\", maxLength: 100)\n        #expect(result == \"Hello 👋 World\")\n    }\n\n    @Test func unicodeCharactersAreAccepted() throws {\n        let result = InputValidator.validateUserString(\"Hello 世界 مرحبا\", maxLength: 100)\n        #expect(result == \"Hello 世界 مرحبا\")\n    }\n\n    @Test func specialCharactersAreAccepted() throws {\n        let result = InputValidator.validateUserString(\"Hello!@#$%^&*()_+-=[]{}|;':\\\",./<>?\", maxLength: 100)\n        #expect(result == \"Hello!@#$%^&*()_+-=[]{}|;':\\\",./<>?\")\n    }\n\n    // MARK: - Nickname Validation Tests\n\n    @Test func validNicknameIsAccepted() throws {\n        let result = InputValidator.validateNickname(\"Alice\")\n        #expect(result == \"Alice\")\n    }\n\n    @Test func nicknameWithEmojiIsAccepted() throws {\n        let result = InputValidator.validateNickname(\"Alice 🚀\")\n        #expect(result == \"Alice 🚀\")\n    }\n\n    @Test func nicknameTooLongIsRejected() throws {\n        let longNickname = String(repeating: \"a\", count: 51)\n        let result = InputValidator.validateNickname(longNickname)\n        #expect(result == nil)\n    }\n\n    @Test func nicknameAtMaxLengthIsAccepted() throws {\n        let exactNickname = String(repeating: \"a\", count: 50)\n        let result = InputValidator.validateNickname(exactNickname)\n        #expect(result == exactNickname)\n    }\n\n    @Test func nicknameWithControlCharacterIsRejected() throws {\n        let result = InputValidator.validateNickname(\"Alice\\u{0000}\")\n        #expect(result == nil)\n    }\n\n    // MARK: - Timestamp Validation Tests\n    // BCH-01-011: Window reduced from ±1 hour to ±5 minutes\n\n    @Test func currentTimestampIsValid() throws {\n        let now = Date()\n        let result = InputValidator.validateTimestamp(now)\n        #expect(result == true)\n    }\n\n    @Test func timestampWithinFiveMinutesIsValid() throws {\n        // 2 minutes ago should be valid (within 5-minute window)\n        let twoMinutesAgo = Date().addingTimeInterval(-2 * 60)\n        let result = InputValidator.validateTimestamp(twoMinutesAgo)\n        #expect(result == true)\n    }\n\n    @Test func timestampThirtyMinutesAgoIsInvalid() throws {\n        // BCH-01-011: 30 minutes is now outside the 5-minute window\n        let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)\n        let result = InputValidator.validateTimestamp(thirtyMinutesAgo)\n        #expect(result == false)\n    }\n\n    @Test func timestampTenMinutesAgoIsInvalid() throws {\n        // 10 minutes is outside the 5-minute window\n        let tenMinutesAgo = Date().addingTimeInterval(-10 * 60)\n        let result = InputValidator.validateTimestamp(tenMinutesAgo)\n        #expect(result == false)\n    }\n\n    @Test func timestampTenMinutesInFutureIsInvalid() throws {\n        // 10 minutes in future is outside the 5-minute window\n        let tenMinutesFromNow = Date().addingTimeInterval(10 * 60)\n        let result = InputValidator.validateTimestamp(tenMinutesFromNow)\n        #expect(result == false)\n    }\n\n    @Test func timestampAtFiveMinuteBoundaryIsValid() throws {\n        // Just slightly within the five-minute window (299 seconds)\n        let almostFiveMinutesAgo = Date().addingTimeInterval(-299)\n        let result = InputValidator.validateTimestamp(almostFiveMinutesAgo)\n        #expect(result == true)\n    }\n\n    @Test func timestampJustOutsideFiveMinuteWindowIsInvalid() throws {\n        // Just outside the five-minute window (301 seconds)\n        let justOverFiveMinutesAgo = Date().addingTimeInterval(-301)\n        let result = InputValidator.validateTimestamp(justOverFiveMinutesAgo)\n        #expect(result == false)\n    }\n\n    // MARK: - Edge Cases\n\n    @Test func singleCharacterStringIsAccepted() throws {\n        let result = InputValidator.validateUserString(\"a\", maxLength: 100)\n        #expect(result == \"a\")\n    }\n\n    @Test func stringWithOnlyNewlinesIsRejected() throws {\n        let result = InputValidator.validateUserString(\"\\n\\n\\n\", maxLength: 100)\n        #expect(result == nil)\n    }\n\n    @Test func stringWithMixedWhitespaceIsTrimmed() throws {\n        let result = InputValidator.validateUserString(\" \\t\\nHello\\n\\t \", maxLength: 100)\n        #expect(result == \"Hello\")\n    }\n\n    @Test func stringWithLeadingControlCharacterIsRejected() throws {\n        let result = InputValidator.validateUserString(\"\\u{0000}Hello\", maxLength: 100)\n        #expect(result == nil)\n    }\n\n    @Test func stringWithTrailingControlCharacterIsRejected() throws {\n        let result = InputValidator.validateUserString(\"Hello\\u{0000}\", maxLength: 100)\n        #expect(result == nil)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Integration/IntegrationTests.swift",
    "content": "//\n// IntegrationTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\nimport CryptoKit\nimport Testing\n@testable import bitchat\n\nstruct IntegrationTests {\n    \n    private var helper = TestNetworkHelper()\n    \n    init() {\n        helper.createNode(\"Alice\", peerID: PeerID(str: UUID().uuidString))\n        helper.createNode(\"Bob\", peerID: PeerID(str: UUID().uuidString))\n        helper.createNode(\"Charlie\", peerID: PeerID(str: UUID().uuidString))\n        helper.createNode(\"David\", peerID: PeerID(str: UUID().uuidString))\n    }\n    \n    // MARK: - Multi-Peer Scenarios\n    \n    @Test func fullMeshCommunication() async throws {\n        helper.connectFullMesh()\n        \n        var messageMatrix: [String: Set<String>] = [:]\n        for (senderName, _) in helper.nodes { messageMatrix[senderName] = [] }\n        \n        for (receiverName, receiver) in helper.nodes {\n            receiver.messageDeliveryHandler = { message in\n                let parts = message.content.components(separatedBy: \" \")\n                if let last = parts.last, message.content.contains(\"Hello from\") {\n                    if receiverName != last {\n                        messageMatrix[last]?.insert(receiverName)\n                    }\n                }\n            }\n        }\n        \n        for (name, node) in helper.nodes {\n            node.sendMessage(\"Hello from \\(name)\")\n        }\n        \n        // Each sender should have reached all other nodes\n        for (sender, receivers) in messageMatrix {\n            let expectedReceivers = Set(helper.nodes.keys.filter { $0 != sender })\n            #expect(receivers == expectedReceivers, \"\\(sender) didn't reach all nodes\")\n        }\n    }\n    \n    @Test func dynamicTopologyChanges() async throws {\n        // Start with Alice -> Bob -> Charlie\n        helper.connect(\"Alice\", \"Bob\")\n        helper.connect(\"Bob\", \"Charlie\")\n        \n        try await confirmation(\"Topology changes handled\") { receiveMessage in\n            var phase = 1\n            \n            helper.nodes[\"Charlie\"]!.messageDeliveryHandler = { message in\n                if phase == 1 && message.sender == \"Alice\" {\n                    // Now change topology: disconnect Bob, connect Alice-Charlie\n                    helper.disconnect(\"Alice\", \"Bob\")\n                    helper.disconnect(\"Bob\", \"Charlie\")\n                    helper.connect(\"Alice\", \"Charlie\")\n                    phase = 2\n                    \n                    // Send another message\n                    helper.nodes[\"Alice\"]!.sendMessage(\"Direct message\")\n                } else if phase == 2 && message.content == \"Direct message\" {\n                    receiveMessage()\n                }\n            }\n            \n            // Allow relay handler to be set before first send\n            try await sleep(0.05)\n            helper.nodes[\"Alice\"]!.sendMessage(\"Relayed message\")\n        }\n    }\n    \n    @Test func networkPartitionRecovery() async throws {\n        // Create two partitions\n        helper.connect(\"Alice\", \"Bob\")\n        helper.connect(\"Charlie\", \"David\")\n        \n        let messagesBeforeMerge = 0\n        var messagesAfterMerge = 0\n        \n        try await confirmation(\"Partitions merge and communicate\") { receiveMessage in\n            // Monitor cross-partition messages\n            helper.nodes[\"David\"]!.messageDeliveryHandler = { message in\n                if message.sender == \"Alice\" {\n                    messagesAfterMerge += 1\n                    if messagesAfterMerge == 1 {\n                        receiveMessage()\n                    }\n                }\n            }\n            \n            // Try to send across partition (should fail)\n            helper.nodes[\"Alice\"]!.sendMessage(\"Before merge\")\n            \n            // Merge partitions after delay\n            try await sleep(0.05)\n            // Connect partitions\n            helper.connect(\"Bob\", \"Charlie\")\n            \n            // Enable relay\n            helper.setupRelay(\"Bob\", nextHops: [\"Charlie\"])\n            helper.setupRelay(\"Charlie\", nextHops: [\"David\"])\n            \n            // Send message across merged network\n            helper.nodes[\"Alice\"]!.sendMessage(\"After merge\")\n        }\n        \n        #expect(messagesBeforeMerge == 0)\n        #expect(messagesAfterMerge == 1)\n    }\n    \n    // MARK: - Mixed Message Type Scenarios\n    \n    @Test func mixedPublicPrivateMessages() async throws {\n        helper.connectFullMesh()\n        \n        var publicCount = 0\n        var privateCount = 0\n        \n        await confirmation(\"Mixed messages handled correctly\") { completion in\n            // Bob monitors messages\n            helper.nodes[\"Bob\"]!.messageDeliveryHandler = { message in\n                if message.isPrivate && message.recipientNickname == \"Bob\" {\n                    privateCount += 1\n                } else if !message.isPrivate {\n                    publicCount += 1\n                }\n                \n                if publicCount == 2 && privateCount == 1 {\n                    completion()\n                }\n            }\n            \n            // Alice sends mixed messages\n            helper.nodes[\"Alice\"]!.sendMessage(\"Public 1\")\n            helper.nodes[\"Alice\"]!.sendPrivateMessage(\"Private to Bob\", to: helper.nodes[\"Bob\"]!.peerID, recipientNickname: \"Bob\")\n            helper.nodes[\"Alice\"]!.sendMessage(\"Public 2\")\n        }\n        \n        #expect(publicCount == 2)\n        #expect(privateCount == 1)\n    }\n    \n    @Test func encryptedAndUnencryptedMix() async throws {\n        helper.connect(\"Alice\", \"Bob\")\n        \n        // Setup Noise session\n        try helper.establishNoiseSession(\"Alice\", \"Bob\")\n        \n        var plainCount = 0\n        var encryptedCount = 0\n        \n        try await confirmation(\"Both encrypted and plain messages work\") { completion in\n            // Plain path: send public message and count at Bob\n            helper.nodes[\"Bob\"]!.messageDeliveryHandler = { message in\n                if message.content == \"Plain message\" {\n                    plainCount += 1\n                }\n                if plainCount == 1 && encryptedCount == 1 {\n                    completion()\n                }\n            }\n            \n            // Encrypted path: use NoiseSessionManager explicitly\n            let plaintext = \"Encrypted message\".data(using: .utf8)!\n            let ciphertext = try helper.noiseManagers[\"Alice\"]!.encrypt(plaintext, for: helper.nodes[\"Bob\"]!.peerID)\n            \n            helper.nodes[\"Bob\"]!.packetDeliveryHandler = { packet in\n                if packet.type == MessageType.noiseEncrypted.rawValue {\n                    if let data = try? helper.noiseManagers[\"Bob\"]!.decrypt(ciphertext, from: helper.nodes[\"Alice\"]!.peerID),\n                       data == plaintext {\n                        encryptedCount = 1\n                        if plainCount == 1 {\n                            completion()\n                        }\n                    }\n                }\n            }\n            \n            helper.nodes[\"Alice\"]!.sendMessage(\"Plain message\")\n            // Deliver encrypted packet directly\n            let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)\n            helper.nodes[\"Bob\"]!.simulateIncomingPacket(encPacket)\n        }\n    }\n    \n    // MARK: - Network Resilience Tests\n    \n    @Test func messageDeliveryUnderChurn() async throws {\n        // Start with stable network\n        helper.connectFullMesh()\n        \n        let totalMessages = 10\n        \n        try await confirmation(\"Messages delivered despite churn\", expectedCount: totalMessages) { completion in\n            // David tracks received messages\n            helper.nodes[\"David\"]!.messageDeliveryHandler = { message in\n                completion()\n            }\n            \n            // Send messages while churning network\n            for i in 0..<totalMessages {\n                helper.nodes[\"Alice\"]!.sendMessage(\"Message \\(i)\")\n                \n                // Simulate churn\n                if i % 3 == 0 {\n                    // Disconnect and reconnect random connection\n                    let pairs = [(\"Alice\", \"Bob\"), (\"Bob\", \"Charlie\"), (\"Charlie\", \"David\")]\n                    let randomPair = pairs.randomElement()!\n                    helper.disconnect(randomPair.0, randomPair.1)\n                    try await sleep(0.01)\n                    helper.connect(randomPair.0, randomPair.1)\n                }\n            }\n        }\n    }\n    \n    @Test func peerPresenceTrackingAndReconnection() async throws {\n        helper.connect(\"Alice\", \"Bob\")\n        \n        await confirmation(\"Delivery after reconnection\") { delivered in\n            helper.nodes[\"Bob\"]!.messageDeliveryHandler = { message in\n                if message.content == \"After reconnect\" {\n                    delivered()\n                }\n            }\n            \n            // Simulate disconnect (out of range)\n            helper.disconnect(\"Alice\", \"Bob\")\n            // Reconnect\n            helper.connect(\"Alice\", \"Bob\")\n            \n            // Send after reconnection\n            helper.nodes[\"Alice\"]!.sendMessage(\"After reconnect\")\n        }\n    }\n    \n    @Test func encryptedMessageAfterPeerRestart() async throws {\n        helper.connect(\"Alice\", \"Bob\")\n        do {\n            try helper.establishNoiseSession(\"Alice\", \"Bob\")\n        } catch {\n            Issue.record(\"Failed to establish Noise session: \\(error)\")\n        }\n        \n        // Exchange an encrypted message\n        await confirmation(\"First message received\") { received in\n            helper.nodes[\"Bob\"]!.messageDeliveryHandler = { message in\n                if message.content == \"Before restart\" && message.isPrivate {\n                    received()\n                }\n            }\n            helper.nodes[\"Alice\"]!.sendPrivateMessage(\"Before restart\", to: helper.nodes[\"Bob\"]!.peerID, recipientNickname: \"Bob\")\n        }\n        \n        // Simulate Bob restart by recreating his Noise manager\n        let bobKey = Curve25519.KeyAgreement.PrivateKey()\n        helper.noiseManagers[\"Bob\"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain)\n        \n        // Re-establish Noise handshake explicitly via managers\n        do {\n            let m1 = try helper.noiseManagers[\"Bob\"]!.initiateHandshake(with: helper.nodes[\"Alice\"]!.peerID)\n            let m2 = try helper.noiseManagers[\"Alice\"]!.handleIncomingHandshake(from: helper.nodes[\"Bob\"]!.peerID, message: m1)!\n            let m3 = try helper.noiseManagers[\"Bob\"]!.handleIncomingHandshake(from: helper.nodes[\"Alice\"]!.peerID, message: m2)!\n            _ = try helper.noiseManagers[\"Alice\"]!.handleIncomingHandshake(from: helper.nodes[\"Bob\"]!.peerID, message: m3)\n        } catch {\n            Issue.record(\"Failed to re-establish Noise session after restart: \\(error)\")\n        }\n        \n        // Now messages should work again - simulate encrypted packet\n        await confirmation(\"Message after restart received\") { received in\n            helper.nodes[\"Alice\"]!.messageDeliveryHandler = { message in\n                if message.content == \"After restart success\" && message.isPrivate {\n                    received()\n                }\n            }\n            \n            do {\n                let plaintext = \"After restart success\".data(using: .utf8)!\n                let ciphertext = try helper.noiseManagers[\"Bob\"]!.encrypt(plaintext, for: helper.nodes[\"Alice\"]!.peerID)\n                let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)\n                helper.nodes[\"Alice\"]!.packetDeliveryHandler = { pkt in\n                    if pkt.type == MessageType.noiseEncrypted.rawValue {\n                        if let data = try? helper.noiseManagers[\"Alice\"]!.decrypt(pkt.payload, from: helper.nodes[\"Bob\"]!.peerID),\n                           String(data: data, encoding: .utf8) == \"After restart success\" {\n                            received()\n                        }\n                    }\n                }\n                helper.nodes[\"Alice\"]!.simulateIncomingPacket(packet)\n            } catch {\n                Issue.record(\"Encryption after restart failed: \\(error)\")\n            }\n        }\n    }\n    \n    @Test func largeScaleNetwork() async throws {\n        // Create larger network\n        for i in 5...10 {\n            helper.createNode(\"Node\\(i)\", peerID: PeerID(str: \"PEER\\(i)\"))\n        }\n        \n        // Connect in ring topology with cross-connections\n        let allNodes = Array(helper.nodes.keys).sorted()\n        for i in 0..<allNodes.count {\n            // Ring connection\n            helper.connect(allNodes[i], allNodes[(i + 1) % allNodes.count])\n            \n            // Cross connection\n            if i + 3 < allNodes.count {\n                helper.connect(allNodes[i], allNodes[i + 3])\n            }\n        }\n        \n        await confirmation(\"Large network handles broadcast\", expectedCount: helper.nodes.count - 1) { nodeReaced in\n            // All nodes except Alice listen\n            for (name, node) in helper.nodes where name != \"Alice\" {\n                node.messageDeliveryHandler = { message in\n                    if message.content == \"Broadcast test\" {\n                        nodeReaced()\n                    }\n                }\n            }\n            \n            // Alice broadcasts\n            helper.nodes[\"Alice\"]!.sendMessage(\"Broadcast test\")\n        }\n    }\n    \n    // MARK: - Stress Tests\n    \n    @Test func highLoadScenario() async throws {\n        helper.connectFullMesh()\n        \n        let messagesPerNode = 25\n        let expectedTotal = messagesPerNode * helper.nodes.count * (helper.nodes.count - 1)\n        \n        await confirmation(\"High load handled\", expectedCount: expectedTotal) { received in\n            // Each node tracks messages\n            for (_, node) in helper.nodes {\n                node.messageDeliveryHandler = { _ in\n                    received()\n                }\n            }\n            \n            // All nodes send many messages simultaneously\n            await withTaskGroup(of: Void.self) { group in\n                for (name, node) in helper.nodes {\n                    group.addTask {\n                        for i in 0..<messagesPerNode {\n                            node.sendMessage(\"\\(name) message \\(i)\")\n                        }\n                    }\n                }\n                await group.waitForAll()\n            }\n        }\n    }\n    \n    @Test func mixedTrafficPatterns() async throws {\n        helper.connectFullMesh()\n        \n        var metrics = [\n            \"public\": 0,\n            \"private\": 0,\n            \"mentions\": 0,\n            \"relayed\": 0\n        ]\n        \n        // Setup complex handlers\n        for (name, node) in helper.nodes {\n            node.messageDeliveryHandler = { message in\n                if message.isPrivate {\n                    metrics[\"private\"]! += 1\n                } else {\n                    metrics[\"public\"]! += 1\n                }\n                \n                if message.mentions?.contains(name) ?? false {\n                    metrics[\"mentions\"]! += 1\n                }\n                \n                if message.isRelay {\n                    metrics[\"relayed\"]! += 1\n                }\n            }\n        }\n        \n        // Generate mixed traffic\n        helper.nodes[\"Alice\"]!.sendMessage(\"Public broadcast\")\n        helper.nodes[\"Alice\"]!.sendPrivateMessage(\"Private to Bob\", to: helper.nodes[\"Bob\"]!.peerID, recipientNickname: \"Bob\")\n        helper.nodes[\"Bob\"]!.sendMessage(\"Mentioning @Charlie\", mentions: [\"Charlie\"])\n        \n        // Disconnect to force relay\n        helper.disconnect(\"Alice\", \"David\")\n        helper.nodes[\"Alice\"]!.sendMessage(\"Needs relay to David\")\n        \n        #expect(metrics[\"public\", default: 0] > 0)\n        #expect(metrics[\"private\", default: 0] > 0)\n        #expect(metrics[\"mentions\", default: 0] > 0)\n    }\n    \n    // MARK: - Security Integration Tests\n    // Replacement for the legacy NACK test: verifies that after a\n    // decryption failure, peers can rehandshake via NoiseSessionManager\n    // and resume secure communication.\n    @Test func rehandshakeAfterDecryptionFailure() throws {\n        // Alice <-> Bob connected\n        helper.connect(\"Alice\", \"Bob\")\n        \n        // Establish initial Noise session\n        try helper.establishNoiseSession(\"Alice\", \"Bob\")\n        \n        guard let aliceManager = helper.noiseManagers[\"Alice\"],\n              let bobManager = helper.noiseManagers[\"Bob\"],\n              let alicePeerID = helper.nodes[\"Alice\"]?.peerID,\n              let bobPeerID = helper.nodes[\"Bob\"]?.peerID\n        else {\n            Issue.record(\"Missing managers or peer IDs\")\n            return\n        }\n        \n        // Baseline: encrypt from Alice, decrypt at Bob\n        let plaintext1 = Data(\"hello-secure\".utf8)\n        let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID)\n        let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID)\n        #expect(decrypted1 == plaintext1)\n        \n        // Simulate decryption failure by corrupting ciphertext\n        let corrupted = encrypted1.prefix(15)\n        #expect(throws: NoiseError.invalidCiphertext) {\n            _ = try bobManager.decrypt(corrupted, from: alicePeerID)\n        }\n        \n        // Bob initiates a new handshake; clear Bob's session first so initiateHandshake won't throw\n        bobManager.removeSession(for: alicePeerID)\n        try helper.establishNoiseSession(\"Bob\", \"Alice\")\n        \n        // After rehandshake, encryption/decryption works again\n        let plaintext2 = Data(\"hello-again\".utf8)\n        let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID)\n        let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID)\n        #expect(decrypted2 == plaintext2)\n    }\n    \n    @Test func endToEndSecurityScenario() async throws {\n        helper.connect(\"Alice\", \"Bob\")\n        helper.connect(\"Bob\", \"Charlie\") // Charlie will try to eavesdrop\n        \n        // Establish secure session between Alice and Bob only\n        try helper.establishNoiseSession(\"Alice\", \"Bob\")\n        \n        await confirmation(\"Secure communication maintained\", expectedCount: 2) { receivedPacket in\n            \n            // Setup encryption at Alice\n            helper.nodes[\"Alice\"]!.packetDeliveryHandler = { packet in\n                if packet.type == 0x01,\n                   let message = BitchatMessage(packet.payload),\n                   message.isPrivate && packet.recipientID != nil {\n                    // Encrypt private messages\n                    if let encrypted = try? helper.noiseManagers[\"Alice\"]!.encrypt(packet.payload, for: helper.nodes[\"Bob\"]!.peerID) {\n                        let encPacket = BitchatPacket(\n                            type: 0x02,\n                            senderID: packet.senderID,\n                            recipientID: packet.recipientID,\n                            timestamp: packet.timestamp,\n                            payload: encrypted,\n                            signature: packet.signature,\n                            ttl: packet.ttl\n                        )\n                        helper.nodes[\"Bob\"]!.simulateIncomingPacket(encPacket)\n                    }\n                }\n            }\n            \n            // Bob can decrypt\n            helper.nodes[\"Bob\"]!.packetDeliveryHandler = { packet in\n                if packet.type == 0x02 {\n                    receivedPacket()\n                    if let decrypted = try? helper.noiseManagers[\"Bob\"]!.decrypt(packet.payload, from: helper.nodes[\"Alice\"]!.peerID) {\n                        #expect(BitchatMessage(decrypted)?.content == \"Secret message\")\n                    } else {\n                        Issue.record(\"Bob was unable to decrypt the message\")\n                    }\n                    \n                    // Relay encrypted packet to Charlie\n                    helper.nodes[\"Charlie\"]!.simulateIncomingPacket(packet)\n                }\n            }\n            \n            // Charlie cannot decrypt\n            helper.nodes[\"Charlie\"]!.packetDeliveryHandler = { packet in\n                if packet.type == 0x02 {\n                    receivedPacket()\n                    #expect(throws: NoiseSessionError.sessionNotFound, \"Charlie should not be able to decrypt\") {\n                        _ = try helper.noiseManagers[\"Charlie\"]?.decrypt(packet.payload, from: helper.nodes[\"Alice\"]!.peerID)\n                    }\n                }\n            }\n            \n            // Send encrypted private message\n            helper.nodes[\"Alice\"]!.sendPrivateMessage(\"Secret message\", to: helper.nodes[\"Bob\"]!.peerID, recipientNickname: \"Bob\")\n        }\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Integration/TestNetworkHelper.swift",
    "content": "//\n// TestNetworkHelper.swift\n// bitchatTests\n//\n// Extracted shared, mutable integration state for nodes and noise sessions.\n// Keeps test containers nonmutating (Swift Testing-friendly).\n//\n\nimport Foundation\nimport CryptoKit\n@testable import bitchat\n\nfinal class TestNetworkHelper {\n    // Public, read-only views for tests; mutation only through methods\n    var nodes: [String: MockBLEService] = [:]\n    var noiseManagers: [String: NoiseSessionManager] = [:]\n    let mockKeychain = MockKeychain()\n    private let bus = MockBLEBus(autoFloodEnabled: true)\n    \n    // MARK: - Node/Manager management\n    \n    @discardableResult\n    func createNode(_ name: String, peerID: PeerID) -> MockBLEService {\n        let node = MockBLEService(bus: bus)\n        node.myPeerID = peerID\n        node.mockNickname = name\n        nodes[name] = node\n        \n        // Create/replace Noise manager for this node\n        let key = Curve25519.KeyAgreement.PrivateKey()\n        noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)\n        return node\n    }\n    \n    func getNode(_ name: String) -> MockBLEService? {\n        nodes[name]\n    }\n    \n    func getManager(_ name: String) -> NoiseSessionManager? {\n        noiseManagers[name]\n    }\n    \n    // MARK: - Topology\n    \n    func connect(_ a: String, _ b: String) {\n        guard let n1 = nodes[a], let n2 = nodes[b] else { return }\n        n1.simulateConnectedPeer(n2.peerID)\n        n2.simulateConnectedPeer(n1.peerID)\n    }\n    \n    func disconnect(_ a: String, _ b: String) {\n        guard let n1 = nodes[a], let n2 = nodes[b] else { return }\n        n1.simulateDisconnectedPeer(n2.peerID)\n        n2.simulateDisconnectedPeer(n1.peerID)\n    }\n    \n    func connectFullMesh() {\n        let names = Array(nodes.keys)\n        for i in 0..<names.count {\n            for j in (i+1)..<names.count {\n                connect(names[i], names[j])\n            }\n        }\n    }\n    \n    // MARK: - Relay\n    \n    func setupRelay(_ nodeName: String, nextHops: [String]) {\n        guard let node = nodes[nodeName] else { return }\n        node.packetDeliveryHandler = { [weak self] packet in\n            guard let self else { return }\n            guard packet.ttl > 1 else { return }\n            \n            if let message = BitchatMessage(packet.payload) {\n                guard message.senderPeerID != node.peerID else { return }\n                \n                let relayMessage = BitchatMessage(\n                    id: message.id,\n                    sender: message.sender,\n                    content: message.content,\n                    timestamp: message.timestamp,\n                    isRelay: true,\n                    originalSender: message.isRelay ? message.originalSender : message.sender,\n                    isPrivate: message.isPrivate,\n                    recipientNickname: message.recipientNickname,\n                    senderPeerID: message.senderPeerID,\n                    mentions: message.mentions\n                )\n                \n                if let relayPayload = relayMessage.toBinaryPayload() {\n                    let relayPacket = BitchatPacket(\n                        type: packet.type,\n                        senderID: packet.senderID,\n                        recipientID: packet.recipientID,\n                        timestamp: packet.timestamp,\n                        payload: relayPayload,\n                        signature: packet.signature,\n                        ttl: packet.ttl - 1\n                    )\n                    \n                    for hop in nextHops {\n                        self.nodes[hop]?.simulateIncomingPacket(relayPacket)\n                    }\n                }\n            }\n        }\n    }\n    \n    // MARK: - Noise sessions\n    \n    func establishNoiseSession(_ node1: String, _ node2: String) throws {\n        guard let manager1 = noiseManagers[node1],\n              let manager2 = noiseManagers[node2],\n              let peer1ID = nodes[node1]?.peerID,\n              let peer2ID = nodes[node2]?.peerID else { return }\n        \n        let msg1 = try manager1.initiateHandshake(with: peer2ID)\n        let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!\n        let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!\n        _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)\n    }\n}\n\n"
  },
  {
    "path": "bitchatTests/KeychainErrorHandlingTests.swift",
    "content": "//\n// KeychainErrorHandlingTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n// BCH-01-009: Tests for proper keychain error classification and handling\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\nstruct KeychainErrorHandlingTests {\n\n    // MARK: - Error Classification Tests\n\n    @Test func keychainReadResult_successIsNotRecoverable() throws {\n        let result = KeychainReadResult.success(Data([1, 2, 3]))\n        #expect(result.isRecoverableError == false)\n    }\n\n    @Test func keychainReadResult_itemNotFoundIsNotRecoverable() throws {\n        let result = KeychainReadResult.itemNotFound\n        #expect(result.isRecoverableError == false)\n    }\n\n    @Test func keychainReadResult_deviceLockedIsRecoverable() throws {\n        let result = KeychainReadResult.deviceLocked\n        #expect(result.isRecoverableError == true)\n    }\n\n    @Test func keychainReadResult_authenticationFailedIsRecoverable() throws {\n        let result = KeychainReadResult.authenticationFailed\n        #expect(result.isRecoverableError == true)\n    }\n\n    @Test func keychainReadResult_accessDeniedIsNotRecoverable() throws {\n        let result = KeychainReadResult.accessDenied\n        #expect(result.isRecoverableError == false)\n    }\n\n    @Test func keychainSaveResult_successIsNotRecoverable() throws {\n        let result = KeychainSaveResult.success\n        #expect(result.isRecoverableError == false)\n    }\n\n    @Test func keychainSaveResult_duplicateItemIsRecoverable() throws {\n        let result = KeychainSaveResult.duplicateItem\n        #expect(result.isRecoverableError == true)\n    }\n\n    @Test func keychainSaveResult_deviceLockedIsRecoverable() throws {\n        let result = KeychainSaveResult.deviceLocked\n        #expect(result.isRecoverableError == true)\n    }\n\n    @Test func keychainSaveResult_storageFullIsNotRecoverable() throws {\n        let result = KeychainSaveResult.storageFull\n        #expect(result.isRecoverableError == false)\n    }\n\n    // MARK: - Mock Keychain Error Simulation Tests\n\n    @Test func mockKeychain_canSimulateReadErrors() throws {\n        let keychain = MockKeychain()\n\n        // Simulate access denied error\n        keychain.simulatedReadError = .accessDenied\n        let result = keychain.getIdentityKeyWithResult(forKey: \"testKey\")\n\n        switch result {\n        case .accessDenied:\n            // Expected\n            break\n        default:\n            throw KeychainTestError(\"Expected accessDenied, got \\(result)\")\n        }\n    }\n\n    @Test func mockKeychain_canSimulateSaveErrors() throws {\n        let keychain = MockKeychain()\n\n        // Simulate storage full error\n        keychain.simulatedSaveError = .storageFull\n        let result = keychain.saveIdentityKeyWithResult(Data([1, 2, 3]), forKey: \"testKey\")\n\n        switch result {\n        case .storageFull:\n            // Expected\n            break\n        default:\n            throw KeychainTestError(\"Expected storageFull, got \\(result)\")\n        }\n    }\n\n    @Test func mockKeychain_returnsItemNotFoundForMissingKey() throws {\n        let keychain = MockKeychain()\n        let result = keychain.getIdentityKeyWithResult(forKey: \"nonExistentKey\")\n\n        switch result {\n        case .itemNotFound:\n            // Expected\n            break\n        default:\n            throw KeychainTestError(\"Expected itemNotFound, got \\(result)\")\n        }\n    }\n\n    @Test func mockKeychain_returnsSuccessForExistingKey() throws {\n        let keychain = MockKeychain()\n        let testData = Data([1, 2, 3, 4, 5])\n\n        // First save the key\n        _ = keychain.saveIdentityKey(testData, forKey: \"existingKey\")\n\n        // Now read it back\n        let result = keychain.getIdentityKeyWithResult(forKey: \"existingKey\")\n\n        switch result {\n        case .success(let data):\n            #expect(data == testData)\n        default:\n            throw KeychainTestError(\"Expected success, got \\(result)\")\n        }\n    }\n\n    @Test func mockKeychain_saveWithResultStoresData() throws {\n        let keychain = MockKeychain()\n        let testData = Data([10, 20, 30])\n\n        let saveResult = keychain.saveIdentityKeyWithResult(testData, forKey: \"newKey\")\n\n        switch saveResult {\n        case .success:\n            // Verify data was stored\n            let readResult = keychain.getIdentityKeyWithResult(forKey: \"newKey\")\n            switch readResult {\n            case .success(let data):\n                #expect(data == testData)\n            default:\n                throw KeychainTestError(\"Expected to read back saved data\")\n            }\n        default:\n            throw KeychainTestError(\"Expected save success, got \\(saveResult)\")\n        }\n    }\n\n    // MARK: - NoiseEncryptionService Integration Tests\n\n    @Test func noiseEncryptionService_generatesNewIdentityWhenMissing() throws {\n        let keychain = MockKeychain()\n\n        // Create service with empty keychain - should generate new identity\n        let service = NoiseEncryptionService(keychain: keychain)\n\n        // Should have generated and saved keys\n        #expect(service.getStaticPublicKeyData().count == 32)\n        #expect(service.getSigningPublicKeyData().count == 32)\n\n        // Keys should be persisted\n        let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: \"noiseStaticKey\")\n        switch noiseKeyResult {\n        case .success:\n            // Expected - key was saved\n            break\n        default:\n            throw KeychainTestError(\"Expected noise key to be saved\")\n        }\n    }\n\n    @Test func noiseEncryptionService_loadsExistingIdentity() throws {\n        let keychain = MockKeychain()\n\n        // Create first service to generate identity\n        let service1 = NoiseEncryptionService(keychain: keychain)\n        let originalPublicKey = service1.getStaticPublicKeyData()\n        let originalSigningKey = service1.getSigningPublicKeyData()\n\n        // Create second service - should load same identity\n        let service2 = NoiseEncryptionService(keychain: keychain)\n\n        #expect(service2.getStaticPublicKeyData() == originalPublicKey)\n        #expect(service2.getSigningPublicKeyData() == originalSigningKey)\n    }\n\n    @Test func noiseEncryptionService_handlesAccessDeniedGracefully() throws {\n        let keychain = MockKeychain()\n        keychain.simulatedReadError = .accessDenied\n\n        // Service should still initialize with ephemeral key\n        let service = NoiseEncryptionService(keychain: keychain)\n\n        // Should have an identity (ephemeral)\n        #expect(service.getStaticPublicKeyData().count == 32)\n        #expect(service.getSigningPublicKeyData().count == 32)\n    }\n\n    @Test func noiseEncryptionService_handlesDeviceLockedGracefully() throws {\n        let keychain = MockKeychain()\n        keychain.simulatedReadError = .deviceLocked\n\n        // Service should still initialize with ephemeral key\n        let service = NoiseEncryptionService(keychain: keychain)\n\n        // Should have an identity (ephemeral)\n        #expect(service.getStaticPublicKeyData().count == 32)\n    }\n}\n\n// Helper error type for tests\nprivate struct KeychainTestError: Error, CustomStringConvertible {\n    let message: String\n    init(_ message: String) { self.message = message }\n    var description: String { message }\n}\n"
  },
  {
    "path": "bitchatTests/Localization/PrimaryLocalizationKeys.json",
    "content": "{\n  \"app\": [\n    \"app_info.app_name\",\n    \"app_info.close\",\n    \"app_info.done\",\n    \"app_info.features.encryption.title\",\n    \"app_info.features.offline.title\",\n    \"app_info.warning.message\",\n    \"common.cancel\",\n    \"common.close\",\n    \"common.copy\",\n    \"common.ok\",\n    \"content.accessibility.people_count\",\n    \"content.accessibility.send_message\",\n    \"content.actions.title\",\n    \"content.alert.bluetooth_required.permission\",\n    \"content.alert.bluetooth_required.settings\",\n    \"content.alert.bluetooth_required.title\",\n    \"content.delivery.delivered_to\",\n    \"content.input.message_placeholder\",\n    \"fingerprint.action.mark_verified\",\n    \"fingerprint.badge.not_verified\",\n    \"fingerprint.badge.verified\",\n    \"fingerprint.title\",\n    \"location_channels.action.open_settings\",\n    \"location_channels.action.request_permissions\",\n    \"location_channels.title\",\n    \"location_notes.header\",\n    \"system.tor.started\"\n  ],\n  \"shareExtension\": [\n    \"share.fallback.shared_link_title\",\n    \"share.status.failed_to_encode\",\n    \"share.status.no_shareable_content\",\n    \"share.status.nothing_to_share\",\n    \"share.status.shared_link\",\n    \"share.status.shared_text\"\n  ],\n  \"expectedValues\": {\n    \"ar\": {\n      \"common.ok\": \"موافق\",\n      \"content.input.message_placeholder\": \"اكتب رسالة...\",\n      \"location_channels.title\": \"#قنوات الموقع\",\n      \"system.tor.started\": \"tor يعمل. كل الدردشة تمر عبر tor للخصوصية.\",\n      \"share.status.shared_text\": \"✓ تم إرسال النص إلى bitchat\",\n      \"share.status.shared_link\": \"✓ تم إرسال الرابط إلى bitchat\",\n      \"share.status.failed_to_encode\": \"تعذر ترميز الرابط\",\n      \"common.cancel\": \"إلغاء\",\n      \"common.close\": \"إغلاق\",\n      \"common.copy\": \"نسخ\",\n      \"app_info.close\": \"إغلاق\",\n      \"app_info.done\": \"تم\",\n      \"content.alert.bluetooth_required.permission\": \"تحتاج bitchat إلى إذن bluetooth للاتصال بالأجهزة القريبة. فعّل الوصول في الإعدادات.\",\n      \"content.alert.bluetooth_required.settings\": \"الإعدادات\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"تشفير طرف لطرف\",\n      \"app_info.features.offline.title\": \"تواصل بدون اتصال\",\n      \"fingerprint.badge.verified\": \"✓ مُتحقق\",\n      \"fingerprint.badge.not_verified\": \"⚠️ غير مُتحقق\",\n      \"fingerprint.action.mark_verified\": \"وضع علامة تم التحقق\",\n      \"location_channels.action.open_settings\": \"فتح الإعدادات\",\n      \"content.actions.title\": \"إجراءات\",\n      \"content.accessibility.send_message\": \"إرسال رسالة\",\n      \"share.status.nothing_to_share\": \"لا شيء لمشاركته\",\n      \"share.status.no_shareable_content\": \"لا محتوى قابلاً للمشاركة\",\n      \"share.fallback.shared_link_title\": \"رابط مشترك\"\n    },\n    \"de\": {\n      \"common.ok\": \"OK\",\n      \"content.input.message_placeholder\": \"nachricht eingeben...\",\n      \"location_channels.title\": \"#standort-kanäle\",\n      \"system.tor.started\": \"tor läuft. der gesamte chat wird über tor geleitet.\",\n      \"share.status.shared_text\": \"✓ text zu bitchat geteilt\",\n      \"share.status.shared_link\": \"✓ link zu bitchat geteilt\",\n      \"share.status.failed_to_encode\": \"link konnte nicht codiert werden\",\n      \"common.cancel\": \"abbrechen\",\n      \"common.close\": \"schließen\",\n      \"common.copy\": \"kopieren\",\n      \"app_info.close\": \"schließen\",\n      \"app_info.done\": \"FERTIG\",\n      \"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.\",\n      \"content.alert.bluetooth_required.settings\": \"einstellungen\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"end-to-end-verschlüsselung\",\n      \"app_info.features.offline.title\": \"offline-kommunikation\",\n      \"fingerprint.badge.verified\": \"✓ VERIFIZIERT\",\n      \"fingerprint.badge.not_verified\": \"⚠️ NICHT VERIFIZIERT\",\n      \"fingerprint.action.mark_verified\": \"als verifiziert markieren\",\n      \"location_channels.action.open_settings\": \"einstellungen öffnen\",\n      \"content.actions.title\": \"aktionen\",\n      \"content.accessibility.send_message\": \"nachricht senden\",\n      \"share.status.nothing_to_share\": \"nichts zum teilen\",\n      \"share.status.no_shareable_content\": \"kein teilbarer inhalt\",\n      \"share.fallback.shared_link_title\": \"geteilter link\"\n    },\n    \"es\": {\n      \"common.ok\": \"aceptar\",\n      \"content.input.message_placeholder\": \"escribe un mensaje...\",\n      \"location_channels.title\": \"#canales de ubicación\",\n      \"system.tor.started\": \"tor se inició. Todo el chat se enruta por Tor para privacidad.\",\n      \"share.status.shared_text\": \"✓ texto compartido con bitchat\",\n      \"share.status.shared_link\": \"✓ enlace compartido con bitchat\",\n      \"share.status.failed_to_encode\": \"no se pudo codificar el enlace\",\n      \"common.cancel\": \"cancelar\",\n      \"common.close\": \"cerrar\",\n      \"common.copy\": \"copiar\",\n      \"app_info.close\": \"cerrar\",\n      \"app_info.done\": \"LISTO\",\n      \"content.alert.bluetooth_required.permission\": \"bitChat necesita permiso de Bluetooth para conectarse con dispositivos cercanos. Habilita el acceso en Ajustes.\",\n      \"content.alert.bluetooth_required.settings\": \"ajustes\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"cifrado de extremo a extremo\",\n      \"app_info.features.offline.title\": \"comunicación sin conexión\",\n      \"fingerprint.badge.verified\": \"✓ VERIFICADO\",\n      \"fingerprint.badge.not_verified\": \"⚠️ NO VERIFICADO\",\n      \"fingerprint.action.mark_verified\": \"marcar como verificado\",\n      \"location_channels.action.open_settings\": \"abrir ajustes\",\n      \"content.actions.title\": \"acciones\",\n      \"content.accessibility.send_message\": \"enviar mensaje\",\n      \"share.status.nothing_to_share\": \"nada que compartir\",\n      \"share.status.no_shareable_content\": \"sin contenido que se pueda compartir\",\n      \"share.fallback.shared_link_title\": \"enlace compartido\"\n    },\n    \"fr\": {\n      \"common.ok\": \"OK\",\n      \"content.input.message_placeholder\": \"écris un message...\",\n      \"location_channels.title\": \"#canaux localisation\",\n      \"system.tor.started\": \"tor a démarré. tout le chat passe par tor pour la confidentialité.\",\n      \"share.status.shared_text\": \"✓ texte partagé vers bitchat\",\n      \"share.status.shared_link\": \"✓ lien partagé vers bitchat\",\n      \"share.status.failed_to_encode\": \"échec de l'encodage du lien\",\n      \"common.cancel\": \"annuler\",\n      \"common.close\": \"fermer\",\n      \"common.copy\": \"copier\",\n      \"app_info.close\": \"fermer\",\n      \"app_info.done\": \"TERMINÉ\",\n      \"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.\",\n      \"content.alert.bluetooth_required.settings\": \"réglages\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"chiffrement de bout en bout\",\n      \"app_info.features.offline.title\": \"communication hors ligne\",\n      \"fingerprint.badge.verified\": \"✓ VÉRIFIÉ\",\n      \"fingerprint.badge.not_verified\": \"⚠️ NON VÉRIFIÉ\",\n      \"fingerprint.action.mark_verified\": \"marquer comme vérifié\",\n      \"location_channels.action.open_settings\": \"ouvrir réglages\",\n      \"content.actions.title\": \"actions\",\n      \"content.accessibility.send_message\": \"envoyer le message\",\n      \"share.status.nothing_to_share\": \"rien à partager\",\n      \"share.status.no_shareable_content\": \"aucun contenu partageable\",\n      \"share.fallback.shared_link_title\": \"lien partagé\"\n    },\n    \"he\": {\n      \"common.ok\": \"OK\",\n      \"content.input.message_placeholder\": \"כתוב הודעה...\",\n      \"location_channels.title\": \"#ערוצי מיקום\",\n      \"system.tor.started\": \"tor פעיל. כל הצ'אט עובר דרך tor לפרטיות.\",\n      \"share.status.shared_text\": \"✓ הטקסט נשלח אל bitchat\",\n      \"share.status.shared_link\": \"✓ הקישור נשלח אל bitchat\",\n      \"share.status.failed_to_encode\": \"לא ניתן לקודד את הקישור\",\n      \"common.cancel\": \"ביטול\",\n      \"common.close\": \"סגור\",\n      \"common.copy\": \"העתק\",\n      \"app_info.close\": \"סגור\",\n      \"app_info.done\": \"בוצע\",\n      \"content.alert.bluetooth_required.permission\": \"bitchat צריכה הרשאת bluetooth כדי להתחבר למכשירים קרובים. אפשר גישה בהגדרות.\",\n      \"content.alert.bluetooth_required.settings\": \"הגדרות\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"הצפנה מקצה לקצה\",\n      \"app_info.features.offline.title\": \"תקשורת לא מקוונת\",\n      \"fingerprint.badge.verified\": \"✓ מאומת\",\n      \"fingerprint.badge.not_verified\": \"⚠️ לא מאומת\",\n      \"fingerprint.action.mark_verified\": \"סמן כמאומת\",\n      \"location_channels.action.open_settings\": \"פתח הגדרות\",\n      \"content.actions.title\": \"פעולות\",\n      \"content.accessibility.send_message\": \"שלח הודעה\",\n      \"share.status.nothing_to_share\": \"אין מה לשתף\",\n      \"share.status.no_shareable_content\": \"אין תוכן שניתן לשתף\",\n      \"share.fallback.shared_link_title\": \"קישור משותף\"\n    },\n    \"id\": {\n      \"common.ok\": \"OK\",\n      \"content.input.message_placeholder\": \"ketik pesan...\",\n      \"location_channels.title\": \"#kanal lokasi\",\n      \"system.tor.started\": \"tor berjalan. seluruh chat dirutekan lewat tor demi privasi.\",\n      \"share.status.shared_text\": \"✓ teks dikirim ke bitchat\",\n      \"share.status.shared_link\": \"✓ tautan dikirim ke bitchat\",\n      \"share.status.failed_to_encode\": \"gagal mengodekan tautan\",\n      \"common.cancel\": \"batal\",\n      \"common.close\": \"tutup\",\n      \"common.copy\": \"salin\",\n      \"app_info.close\": \"tutup\",\n      \"app_info.done\": \"SELESAI\",\n      \"content.alert.bluetooth_required.permission\": \"bitchat memerlukan izin bluetooth untuk terhubung dengan perangkat dekat. aktifkan akses di pengaturan.\",\n      \"content.alert.bluetooth_required.settings\": \"pengaturan\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"enkripsi ujung ke ujung\",\n      \"app_info.features.offline.title\": \"komunikasi offline\",\n      \"fingerprint.badge.verified\": \"✓ TERVERIFIKASI\",\n      \"fingerprint.badge.not_verified\": \"⚠️ BELUM TERVERIFIKASI\",\n      \"fingerprint.action.mark_verified\": \"tandai sebagai terverifikasi\",\n      \"location_channels.action.open_settings\": \"buka pengaturan\",\n      \"content.actions.title\": \"aksi\",\n      \"content.accessibility.send_message\": \"kirim pesan\",\n      \"share.status.nothing_to_share\": \"tidak ada yang bisa dibagikan\",\n      \"share.status.no_shareable_content\": \"tidak ada konten yang bisa dibagikan\",\n      \"share.fallback.shared_link_title\": \"tautan dibagikan\"\n    },\n    \"it\": {\n      \"common.ok\": \"OK\",\n      \"content.input.message_placeholder\": \"scrivi un messaggio...\",\n      \"location_channels.title\": \"#canali posizione\",\n      \"system.tor.started\": \"tor è avviato. tutta la chat passa da tor per la privacy.\",\n      \"share.status.shared_text\": \"✓ testo inviato a bitchat\",\n      \"share.status.shared_link\": \"✓ link inviato a bitchat\",\n      \"share.status.failed_to_encode\": \"impossibile codificare il link\",\n      \"common.cancel\": \"annulla\",\n      \"common.close\": \"chiudi\",\n      \"common.copy\": \"copia\",\n      \"app_info.close\": \"chiudi\",\n      \"app_info.done\": \"FATTO\",\n      \"content.alert.bluetooth_required.permission\": \"bitchat richiede l'autorizzazione bluetooth per collegarsi ai dispositivi vicini. abilita l'accesso nelle impostazioni.\",\n      \"content.alert.bluetooth_required.settings\": \"impostazioni\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"crittografia end-to-end\",\n      \"app_info.features.offline.title\": \"comunicazione offline\",\n      \"fingerprint.badge.verified\": \"✓ VERIFICATO\",\n      \"fingerprint.badge.not_verified\": \"⚠️ NON VERIFICATO\",\n      \"fingerprint.action.mark_verified\": \"segna come verificato\",\n      \"location_channels.action.open_settings\": \"apri impostazioni\",\n      \"content.actions.title\": \"azioni\",\n      \"content.accessibility.send_message\": \"invia messaggio\",\n      \"share.status.nothing_to_share\": \"niente da condividere\",\n      \"share.status.no_shareable_content\": \"nessun contenuto condivisibile\",\n      \"share.fallback.shared_link_title\": \"link condiviso\"\n    },\n    \"ja\": {\n      \"common.ok\": \"OK\",\n      \"content.input.message_placeholder\": \"メッセージを入力...\",\n      \"location_channels.title\": \"#ロケーションチャンネル\",\n      \"system.tor.started\": \"torを起動しました。全チャットをtor経由で配信します。\",\n      \"share.status.shared_text\": \"✓ bitchatにテキストを共有\",\n      \"share.status.shared_link\": \"✓ bitchatにリンクを共有\",\n      \"share.status.failed_to_encode\": \"リンクのエンコードに失敗しました\",\n      \"common.cancel\": \"キャンセル\",\n      \"common.close\": \"閉じる\",\n      \"common.copy\": \"コピー\",\n      \"app_info.close\": \"閉じる\",\n      \"app_info.done\": \"完了\",\n      \"content.alert.bluetooth_required.permission\": \"bitchatは近くのデバイスと接続するためbluetooth権限が必要です。設定でアクセスを有効にしてください。\",\n      \"content.alert.bluetooth_required.settings\": \"設定\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"エンドツーエンド暗号\",\n      \"app_info.features.offline.title\": \"オフライン通信\",\n      \"fingerprint.badge.verified\": \"✓ 検証済み\",\n      \"fingerprint.badge.not_verified\": \"⚠️ 未検証\",\n      \"fingerprint.action.mark_verified\": \"検証済みにする\",\n      \"location_channels.action.open_settings\": \"設定を開く\",\n      \"content.actions.title\": \"アクション\",\n      \"content.accessibility.send_message\": \"メッセージ送信\",\n      \"share.status.nothing_to_share\": \"共有できるものがありません\",\n      \"share.status.no_shareable_content\": \"共有可能なコンテンツがありません\",\n      \"share.fallback.shared_link_title\": \"共有リンク\"\n    },\n    \"ko\": {\n      \"common.ok\": \"확인\",\n      \"content.input.message_placeholder\": \"메시지를 입력하세요...\",\n      \"location_channels.title\": \"#위치 채널\",\n      \"system.tor.started\": \"tor가 시작되었습니다. IP 보호를 위해 모든 대화를 tor를 통해 라우팅합니다.\",\n      \"share.status.shared_text\": \"✓ bitchat으로 텍스트를 공유했습니다\",\n      \"share.status.shared_link\": \"✓ bitchat으로 링크를 공유했습니다\",\n      \"share.status.failed_to_encode\": \"링크를 인코딩하는 데 실패했습니다\",\n      \"common.cancel\": \"취소\",\n      \"common.close\": \"닫기\",\n      \"common.copy\": \"복사\",\n      \"app_info.close\": \"닫기\",\n      \"app_info.done\": \"확인\",\n      \"content.alert.bluetooth_required.permission\": \"bitchat은 주변 기기와 연결하기 위해 bluetooth 권한이 필요합니다. 설정에서 bluetooth 접근을 활성화해주세요.\",\n      \"content.alert.bluetooth_required.settings\": \"설정\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"종단간 암호화\",\n      \"app_info.features.offline.title\": \"오프라인 통신\",\n      \"fingerprint.badge.verified\": \"✓ 인증됨\",\n      \"fingerprint.badge.not_verified\": \"⚠️ 인증되지 않음\",\n      \"fingerprint.action.mark_verified\": \"인증됨으로 표시\",\n      \"location_channels.action.open_settings\": \"설정 열기\",\n      \"content.actions.title\": \"작업\",\n      \"content.accessibility.send_message\": \"메시지 보내기\",\n      \"share.status.nothing_to_share\": \"공유할 내용이 없습니다\",\n      \"share.status.no_shareable_content\": \"공유할 수 있는 내용이 없습니다\",\n      \"share.fallback.shared_link_title\": \"공유된 링크\"\n    },\n    \"ne\": {\n      \"common.ok\": \"ठिक\",\n      \"content.input.message_placeholder\": \"सन्देश टाइप गर...\",\n      \"location_channels.title\": \"#स्थान च्यानल\",\n      \"system.tor.started\": \"tor सुरु भयो। गोपनीयताका लागि पूरा च्याट tor मार्फत जान्छ।\",\n      \"share.status.shared_text\": \"✓ bitchat मा पाठ पठाइयो\",\n      \"share.status.shared_link\": \"✓ bitchat मा लिङ्क पठाइयो\",\n      \"share.status.failed_to_encode\": \"लिङ्क सङ्केत गर्न सकेन\",\n      \"common.cancel\": \"रद्द\",\n      \"common.close\": \"बन्द\",\n      \"common.copy\": \"प्रतिलिपि\",\n      \"app_info.close\": \"बन्द\",\n      \"app_info.done\": \"सम्पन्न\",\n      \"content.alert.bluetooth_required.permission\": \"bitchat लाई नजिकका उपकरणसँग जडान हुन bluetooth अनुमति चाहिन्छ। सेटिङमा पहुँच सक्षम गर।\",\n      \"content.alert.bluetooth_required.settings\": \"सेटिङ\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"एन्ड-टु-एन्ड सङ्केत\",\n      \"app_info.features.offline.title\": \"अफलाइन सञ्चार\",\n      \"fingerprint.badge.verified\": \"✓ प्रमाणित\",\n      \"fingerprint.badge.not_verified\": \"⚠️ प्रमाणित छैन\",\n      \"fingerprint.action.mark_verified\": \"प्रमाणित चिन्ह लगाउ\",\n      \"location_channels.action.open_settings\": \"सेटिङ खोल\",\n      \"content.actions.title\": \"कार्य\",\n      \"content.accessibility.send_message\": \"सन्देश पठाउ\",\n      \"share.status.nothing_to_share\": \"बाँड्ने केही छैन\",\n      \"share.status.no_shareable_content\": \"बाँड्न मिल्ने सामग्री छैन\",\n      \"share.fallback.shared_link_title\": \"साझा गरिएको लिङ्क\"\n    },\n    \"pt-BR\": {\n      \"common.ok\": \"OK\",\n      \"content.input.message_placeholder\": \"digite uma mensagem...\",\n      \"location_channels.title\": \"#canais de localização\",\n      \"system.tor.started\": \"tor iniciou. todo o chat é roteado por tor para privacidade.\",\n      \"share.status.shared_text\": \"✓ texto enviado para bitchat\",\n      \"share.status.shared_link\": \"✓ link enviado para bitchat\",\n      \"share.status.failed_to_encode\": \"falha ao codificar link\",\n      \"common.cancel\": \"cancelar\",\n      \"common.close\": \"fechar\",\n      \"common.copy\": \"copiar\",\n      \"app_info.close\": \"fechar\",\n      \"app_info.done\": \"CONCLUÍDO\",\n      \"content.alert.bluetooth_required.permission\": \"bitchat precisa de permissão de bluetooth para conectar com dispositivos próximos. habilite o acesso em ajustes.\",\n      \"content.alert.bluetooth_required.settings\": \"ajustes\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"criptografia ponto a ponto\",\n      \"app_info.features.offline.title\": \"comunicação offline\",\n      \"fingerprint.badge.verified\": \"✓ VERIFICADO\",\n      \"fingerprint.badge.not_verified\": \"⚠️ NÃO VERIFICADO\",\n      \"fingerprint.action.mark_verified\": \"marcar como verificado\",\n      \"location_channels.action.open_settings\": \"abrir ajustes\",\n      \"content.actions.title\": \"ações\",\n      \"content.accessibility.send_message\": \"enviar mensagem\",\n      \"share.status.nothing_to_share\": \"nada para compartilhar\",\n      \"share.status.no_shareable_content\": \"nenhum conteúdo compartilhável\",\n      \"share.fallback.shared_link_title\": \"link compartilhado\"\n    },\n    \"ru\": {\n      \"common.ok\": \"OK\",\n      \"content.input.message_placeholder\": \"напиши сообщение...\",\n      \"location_channels.title\": \"#каналы локации\",\n      \"system.tor.started\": \"tor запущен. весь чат идёт через tor для приватности.\",\n      \"share.status.shared_text\": \"✓ текст отправлен в bitchat\",\n      \"share.status.shared_link\": \"✓ ссылка отправлена в bitchat\",\n      \"share.status.failed_to_encode\": \"не удалось закодировать ссылку\",\n      \"common.cancel\": \"отмена\",\n      \"common.close\": \"закрыть\",\n      \"common.copy\": \"копировать\",\n      \"app_info.close\": \"закрыть\",\n      \"app_info.done\": \"ГОТОВО\",\n      \"content.alert.bluetooth_required.permission\": \"bitchat нужен доступ к bluetooth, чтобы соединяться с ближайшими устройствами. включи разрешение в настройках.\",\n      \"content.alert.bluetooth_required.settings\": \"настройки\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"сквозное шифрование\",\n      \"app_info.features.offline.title\": \"офлайн-связь\",\n      \"fingerprint.badge.verified\": \"✓ ПРОВЕРЕНО\",\n      \"fingerprint.badge.not_verified\": \"⚠️ НЕ ПРОВЕРЕНО\",\n      \"fingerprint.action.mark_verified\": \"пометить как проверено\",\n      \"location_channels.action.open_settings\": \"открыть настройки\",\n      \"content.actions.title\": \"действия\",\n      \"content.accessibility.send_message\": \"отправить сообщение\",\n      \"share.status.nothing_to_share\": \"нечем поделиться\",\n      \"share.status.no_shareable_content\": \"нет подходящего контента\",\n      \"share.fallback.shared_link_title\": \"поделился ссылкой\"\n    },\n    \"uk\": {\n      \"common.ok\": \"OK\",\n      \"content.input.message_placeholder\": \"напиши повідомлення...\",\n      \"location_channels.title\": \"#канали локації\",\n      \"system.tor.started\": \"tor запущено. увесь чат іде через tor для приватності.\",\n      \"share.status.shared_text\": \"✓ текст надіслано в bitchat\",\n      \"share.status.shared_link\": \"✓ посилання надіслано в bitchat\",\n      \"share.status.failed_to_encode\": \"не вдалося закодувати посилання\",\n      \"common.cancel\": \"скасувати\",\n      \"common.close\": \"закрити\",\n      \"common.copy\": \"скопіювати\",\n      \"app_info.close\": \"закрити\",\n      \"app_info.done\": \"ГОТОВО\",\n      \"content.alert.bluetooth_required.permission\": \"bitchat потребує дозволу bluetooth для з'єднання з пристроями поруч. ввімкни доступ у налаштуваннях.\",\n      \"content.alert.bluetooth_required.settings\": \"налаштування\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"скрізьове шифрування\",\n      \"app_info.features.offline.title\": \"офлайн-зв'язок\",\n      \"fingerprint.badge.verified\": \"✓ ПЕРЕВІРЕНО\",\n      \"fingerprint.badge.not_verified\": \"⚠️ НЕ ПЕРЕВІРЕНО\",\n      \"fingerprint.action.mark_verified\": \"позначити як перевірено\",\n      \"location_channels.action.open_settings\": \"відкрити налаштування\",\n      \"content.actions.title\": \"дії\",\n      \"content.accessibility.send_message\": \"надіслати повідомлення\",\n      \"share.status.nothing_to_share\": \"нема чим ділитися\",\n      \"share.status.no_shareable_content\": \"нема відповідного контенту\",\n      \"share.fallback.shared_link_title\": \"спільне посилання\"\n    },\n    \"zh-Hans\": {\n      \"common.ok\": \"确定\",\n      \"content.input.message_placeholder\": \"输入消息...\",\n      \"location_channels.title\": \"#位置频道\",\n      \"system.tor.started\": \"tor 已启动。所有聊天通过 tor 路由以保护 IP。\",\n      \"share.status.shared_text\": \"✓ 已将文本分享至 bitchat\",\n      \"share.status.shared_link\": \"✓ 已将链接分享至 bitchat\",\n      \"share.status.failed_to_encode\": \"无法编码链接\",\n      \"common.cancel\": \"取消\",\n      \"common.close\": \"关闭\",\n      \"common.copy\": \"复制\",\n      \"app_info.close\": \"关闭\",\n      \"app_info.done\": \"完成\",\n      \"content.alert.bluetooth_required.permission\": \"bitchat 需要 bluetooth 权限以连接附近设备。请在设置中启用访问。\",\n      \"content.alert.bluetooth_required.settings\": \"设置\",\n      \"app_info.app_name\": \"bitchat\",\n      \"app_info.features.encryption.title\": \"端到端加密\",\n      \"app_info.features.offline.title\": \"离线通信\",\n      \"fingerprint.badge.verified\": \"✓ 已验证\",\n      \"fingerprint.badge.not_verified\": \"⚠️ 未验证\",\n      \"fingerprint.action.mark_verified\": \"标记为已验证\",\n      \"location_channels.action.open_settings\": \"打开设置\",\n      \"content.actions.title\": \"操作\",\n      \"content.accessibility.send_message\": \"发送消息\",\n      \"share.status.nothing_to_share\": \"没有可分享的内容\",\n      \"share.status.no_shareable_content\": \"没有可分享的素材\",\n      \"share.fallback.shared_link_title\": \"分享的链接\"\n    }\n  },\n  \"testLocales\": [\n    \"en\",\n    \"ar\",\n    \"de\",\n    \"es\",\n    \"fr\",\n    \"he\",\n    \"id\",\n    \"it\",\n    \"ja\",\n    \"ko\",\n    \"ne\",\n    \"pt-BR\",\n    \"ru\",\n    \"uk\",\n    \"zh-Hans\"\n  ]\n}"
  },
  {
    "path": "bitchatTests/LocationChannelsTests.swift",
    "content": "import Testing\nimport Foundation\n@testable import bitchat\n\nstruct LocationChannelsTests {\n    @Test func geohashEncoderPrecisionMapping() {\n        // Sanity: known coords (Statue of Liberty approx)\n        let lat = 40.6892\n        let lon = -74.0445\n        let block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision)\n        let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.precision)\n        let city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision)\n        let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision)\n        let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)\n        \n        #expect(block.count == 7)\n        #expect(neighborhood.count == 6)\n        #expect(city.count == 5)\n        #expect(region.count == 4)\n        #expect(country.count == 2)\n        \n        // All prefixes must match progressively\n        #expect(block.hasPrefix(neighborhood))\n        #expect(neighborhood.hasPrefix(city))\n        #expect(city.hasPrefix(region))\n        #expect(region.hasPrefix(country))\n    }\n\n    @Test func nostrGeohashFilterEncoding() throws {\n        let gh = \"u4pruy\"\n        let filter = NostrFilter.geohashEphemeral(gh)\n        let data = try JSONEncoder().encode(filter)\n        let json = String(data: data, encoding: .utf8) ?? \"\"\n        // Expect kinds includes 20000 and tag filter '#g':[gh]\n        #expect(json.contains(\"20000\"))\n        #expect(json.contains(\"\\\"#g\\\":[\\\"\\(gh)\\\"]\"))\n    }\n\n    @Test func perGeohashIdentityDeterministic() throws {\n        // Derive twice for same geohash; should be identical\n        let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())\n        let gh = \"u4pruy\"\n        let id1 = try idBridge.deriveIdentity(forGeohash: gh)\n        let id2 = try idBridge.deriveIdentity(forGeohash: gh)\n        #expect(id1.publicKeyHex == id2.publicKeyHex)\n    }\n\n    @Test func geohashNeighborsNearPoleSkipOutOfBoundsCells() {\n        let nearPole = Geohash.encode(latitude: 89.9999, longitude: 0.0, precision: 8)\n        let neighbors = Geohash.neighbors(of: nearPole)\n\n        #expect(neighbors.isEmpty == false)\n        #expect(neighbors.count < 8)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/LocationNotesManagerTests.swift",
    "content": "import Testing\nimport Foundation\n@testable import bitchat\n\n@MainActor\nstruct LocationNotesManagerTests {\n    @Test\n    func subscribeWithoutRelays_setsNoRelaysState() {\n        var subscribeCalled = false\n        let deps = LocationNotesDependencies(\n            relayLookup: { _, _ in [] },\n            subscribe: { _, _, _, _, _ in\n                subscribeCalled = true\n            },\n            unsubscribe: { _ in },\n            sendEvent: { _, _ in },\n            deriveIdentity: { _ in try NostrIdentity.generate() },\n            now: { Date() }\n        )\n\n        let manager = LocationNotesManager(geohash: \"u4pruydq\", dependencies: deps)\n\n        #expect(subscribeCalled == false)\n        #expect(manager.state == .noRelays)\n        #expect(manager.initialLoadComplete)\n        #expect(manager.errorMessage == String(localized: \"location_notes.error.no_relays\"))\n    }\n\n    @Test\n    func sendWithoutRelays_surfacesNoRelaysError() {\n        var sendCalled = false\n        let deps = LocationNotesDependencies(\n            relayLookup: { _, _ in [] },\n            subscribe: { _, _, _, _, _ in },\n            unsubscribe: { _ in },\n            sendEvent: { _, _ in sendCalled = true },\n            deriveIdentity: { _ in throw TestError.shouldNotDerive },\n            now: { Date() }\n        )\n\n        let manager = LocationNotesManager(geohash: \"u4pruydq\", dependencies: deps)\n        manager.send(content: \"hello\", nickname: \"tester\")\n\n        #expect(sendCalled == false)\n        #expect(manager.state == .noRelays)\n        #expect(manager.errorMessage == String(localized: \"location_notes.error.no_relays\"))\n    }\n\n    @Test func subscribeUsesGeoRelaysAndAppendsNotes() throws {\n        var relaysCaptured: [String] = []\n        var storedHandler: ((NostrEvent) -> Void)?\n        var storedEOSE: (() -> Void)?\n        let deps = LocationNotesDependencies(\n            relayLookup: { _, _ in [\"wss://relay.one\"] },\n            subscribe: { filter, id, relays, handler, eose in\n                #expect(filter.kinds == [1])\n                #expect(!id.isEmpty)\n                relaysCaptured = relays\n                storedHandler = handler\n                storedEOSE = eose\n            },\n            unsubscribe: { _ in },\n            sendEvent: { _, _ in },\n            deriveIdentity: { _ in throw TestError.shouldNotDerive },\n            now: { Date() }\n        )\n\n        let manager = LocationNotesManager(geohash: \"u4pruydq\", dependencies: deps)\n        #expect(relaysCaptured == [\"wss://relay.one\"])\n        #expect(manager.state == .loading)\n\n        let identity = try NostrIdentity.generate()\n        let event = NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .textNote,\n            tags: [[\"g\", \"u4pruydq\"]],\n            content: \"hi\"\n        )\n        let signed = try event.sign(with: identity.schnorrSigningKey())\n        storedHandler?(signed)\n        storedEOSE?()\n\n        #expect(manager.state == .ready)\n        #expect(manager.notes.count == 1)\n        #expect(manager.notes.first?.content == \"hi\")\n    }\n\n    @Test\n    func setGeohash_invalidValueIsIgnored() {\n        var subscribeCount = 0\n        let deps = LocationNotesDependencies(\n            relayLookup: { _, _ in [\"wss://relay.one\"] },\n            subscribe: { _, _, _, _, _ in\n                subscribeCount += 1\n            },\n            unsubscribe: { _ in },\n            sendEvent: { _, _ in },\n            deriveIdentity: { _ in try NostrIdentity.generate() },\n            now: { Date() }\n        )\n\n        let manager = LocationNotesManager(geohash: \"u4pruydq\", dependencies: deps)\n        manager.setGeohash(\"not-valid\")\n\n        #expect(manager.geohash == \"u4pruydq\")\n        #expect(subscribeCount == 1)\n    }\n\n    @Test\n    func refreshAndCancel_manageSubscriptions() {\n        var subscribeIDs: [String] = []\n        var unsubscribedIDs: [String] = []\n        let deps = LocationNotesDependencies(\n            relayLookup: { _, _ in [\"wss://relay.one\"] },\n            subscribe: { _, id, _, _, _ in\n                subscribeIDs.append(id)\n            },\n            unsubscribe: { id in\n                unsubscribedIDs.append(id)\n            },\n            sendEvent: { _, _ in },\n            deriveIdentity: { _ in try NostrIdentity.generate() },\n            now: { Date() }\n        )\n\n        let manager = LocationNotesManager(geohash: \"u4pruydq\", dependencies: deps)\n        manager.refresh()\n        manager.cancel()\n\n        #expect(subscribeIDs.count == 2)\n        #expect(unsubscribedIDs.count == 2)\n        #expect(manager.state == .idle)\n        #expect(manager.errorMessage == nil)\n    }\n\n    @Test\n    func send_successCreatesLocalEchoAndClearsError() throws {\n        var sentEvents: [NostrEvent] = []\n        let identity = try NostrIdentity.generate()\n        let deps = LocationNotesDependencies(\n            relayLookup: { _, _ in [\"wss://relay.one\"] },\n            subscribe: { _, _, _, _, _ in },\n            unsubscribe: { _ in },\n            sendEvent: { event, _ in\n                sentEvents.append(event)\n            },\n            deriveIdentity: { _ in identity },\n            now: { Date(timeIntervalSince1970: 123_456) }\n        )\n\n        let manager = LocationNotesManager(geohash: \"u4pruydq\", dependencies: deps)\n        manager.send(content: \"  hello note  \", nickname: \"Builder\")\n\n        #expect(sentEvents.count == 1)\n        #expect(manager.state == .ready)\n        #expect(manager.errorMessage == nil)\n        #expect(manager.notes.first?.content == \"hello note\")\n        #expect(manager.notes.first?.displayName.hasPrefix(\"Builder#\") == true)\n    }\n\n    @Test\n    func send_failureFormatsErrorMessageAndClearErrorRemovesIt() {\n        let deps = LocationNotesDependencies(\n            relayLookup: { _, _ in [\"wss://relay.one\"] },\n            subscribe: { _, _, _, _, _ in },\n            unsubscribe: { _ in },\n            sendEvent: { _, _ in },\n            deriveIdentity: { _ in throw TestError.shouldNotDerive },\n            now: { Date() }\n        )\n\n        let manager = LocationNotesManager(geohash: \"u4pruydq\", dependencies: deps)\n        manager.send(content: \"hello\", nickname: \"Builder\")\n\n        #expect(manager.errorMessage?.isEmpty == false)\n\n        manager.clearError()\n\n        #expect(manager.errorMessage == nil)\n    }\n\n    private enum TestError: Error {\n        case shouldNotDerive\n    }\n}\n"
  },
  {
    "path": "bitchatTests/MessageDeduplicationServiceTests.swift",
    "content": "//\n// MessageDeduplicationServiceTests.swift\n// bitchatTests\n//\n// Tests for MessageDeduplicationService, LRUDeduplicationCache, and ContentNormalizer.\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\n// MARK: - LRU Deduplication Cache Tests\n\n@Suite(\"LRU Deduplication Cache\")\n@MainActor\nstruct LRUDeduplicationCacheTests {\n\n    // MARK: - Basic Operations\n\n    @Test func emptyCache_containsReturnsFalse() {\n        let cache = LRUDeduplicationCache<Int>(capacity: 10)\n        #expect(!cache.contains(\"key\"))\n        #expect(cache.value(for: \"key\") == nil)\n        #expect(cache.count == 0)\n    }\n\n    @Test func record_addsEntry() {\n        let cache = LRUDeduplicationCache<Int>(capacity: 10)\n        cache.record(\"key1\", value: 42)\n\n        #expect(cache.contains(\"key1\"))\n        #expect(cache.value(for: \"key1\") == 42)\n        #expect(cache.count == 1)\n    }\n\n    @Test func record_updatesExistingEntry() {\n        let cache = LRUDeduplicationCache<Int>(capacity: 10)\n        cache.record(\"key1\", value: 42)\n        cache.record(\"key1\", value: 100)\n\n        #expect(cache.value(for: \"key1\") == 100)\n        #expect(cache.count == 1) // Should not increase count\n    }\n\n    @Test func record_multipleEntries() {\n        let cache = LRUDeduplicationCache<String>(capacity: 10)\n        cache.record(\"a\", value: \"alpha\")\n        cache.record(\"b\", value: \"beta\")\n        cache.record(\"c\", value: \"gamma\")\n\n        #expect(cache.count == 3)\n        #expect(cache.value(for: \"a\") == \"alpha\")\n        #expect(cache.value(for: \"b\") == \"beta\")\n        #expect(cache.value(for: \"c\") == \"gamma\")\n    }\n\n    @Test func remove_removesEntry() {\n        let cache = LRUDeduplicationCache<Int>(capacity: 10)\n        cache.record(\"key1\", value: 42)\n        cache.record(\"key2\", value: 100)\n\n        cache.remove(\"key1\")\n\n        #expect(!cache.contains(\"key1\"))\n        #expect(cache.contains(\"key2\"))\n    }\n\n    @Test func clear_removesAllEntries() {\n        let cache = LRUDeduplicationCache<Int>(capacity: 10)\n        cache.record(\"a\", value: 1)\n        cache.record(\"b\", value: 2)\n        cache.record(\"c\", value: 3)\n\n        cache.clear()\n\n        #expect(cache.count == 0)\n        #expect(!cache.contains(\"a\"))\n        #expect(!cache.contains(\"b\"))\n        #expect(!cache.contains(\"c\"))\n    }\n\n    // MARK: - Eviction Tests\n\n    @Test func eviction_removesOldestWhenOverCapacity() {\n        let cache = LRUDeduplicationCache<Int>(capacity: 3)\n        cache.record(\"a\", value: 1)\n        cache.record(\"b\", value: 2)\n        cache.record(\"c\", value: 3)\n        cache.record(\"d\", value: 4) // Should evict \"a\"\n\n        #expect(cache.count == 3)\n        #expect(!cache.contains(\"a\")) // Evicted\n        #expect(cache.contains(\"b\"))\n        #expect(cache.contains(\"c\"))\n        #expect(cache.contains(\"d\"))\n    }\n\n    @Test func eviction_maintainsCapacity() {\n        let cache = LRUDeduplicationCache<Int>(capacity: 2)\n\n        for i in 0..<100 {\n            cache.record(\"key\\(i)\", value: i)\n        }\n\n        #expect(cache.count == 2)\n        // Most recent entries should be present\n        #expect(cache.contains(\"key99\"))\n        #expect(cache.contains(\"key98\"))\n        // Older entries should be evicted\n        #expect(!cache.contains(\"key0\"))\n        #expect(!cache.contains(\"key50\"))\n    }\n\n    @Test func eviction_capacityOfOne() {\n        let cache = LRUDeduplicationCache<Int>(capacity: 1)\n        cache.record(\"a\", value: 1)\n        cache.record(\"b\", value: 2)\n\n        #expect(cache.count == 1)\n        #expect(!cache.contains(\"a\"))\n        #expect(cache.contains(\"b\"))\n    }\n\n    @Test func eviction_skipsRemovedKeys() {\n        let cache = LRUDeduplicationCache<Int>(capacity: 3)\n        cache.record(\"a\", value: 1)\n        cache.record(\"b\", value: 2)\n        cache.record(\"c\", value: 3)\n\n        // Remove \"a\" manually\n        cache.remove(\"a\")\n\n        // Add new entry - should evict \"b\" (next oldest still in map)\n        cache.record(\"d\", value: 4)\n\n        // Cache should have b, c, d (a was removed)\n        // Actually after eviction it should have c, d and maybe b depending on implementation\n        #expect(!cache.contains(\"a\"))\n        #expect(cache.count <= 3)\n    }\n\n    // MARK: - Edge Cases\n\n    @Test func emptyKey_works() {\n        let cache = LRUDeduplicationCache<Int>(capacity: 10)\n        cache.record(\"\", value: 42)\n\n        #expect(cache.contains(\"\"))\n        #expect(cache.value(for: \"\") == 42)\n    }\n\n    @Test func largeCapacity_works() {\n        let cache = LRUDeduplicationCache<Int>(capacity: 10000)\n\n        for i in 0..<5000 {\n            cache.record(\"key\\(i)\", value: i)\n        }\n\n        #expect(cache.count == 5000)\n        #expect(cache.contains(\"key0\"))\n        #expect(cache.contains(\"key4999\"))\n    }\n}\n\n// MARK: - Content Normalizer Tests\n\nstruct ContentNormalizerTests {\n\n    @Test func normalizedKey_basicContent() {\n        let key1 = ContentNormalizer.normalizedKey(\"Hello World\")\n        let key2 = ContentNormalizer.normalizedKey(\"Hello World\")\n        #expect(key1 == key2)\n    }\n\n    @Test func normalizedKey_caseInsensitive() {\n        let key1 = ContentNormalizer.normalizedKey(\"Hello World\")\n        let key2 = ContentNormalizer.normalizedKey(\"hello world\")\n        let key3 = ContentNormalizer.normalizedKey(\"HELLO WORLD\")\n        #expect(key1 == key2)\n        #expect(key2 == key3)\n    }\n\n    @Test func normalizedKey_whitespaceCollapsed() {\n        let key1 = ContentNormalizer.normalizedKey(\"Hello World\")\n        let key2 = ContentNormalizer.normalizedKey(\"Hello    World\")\n        let key3 = ContentNormalizer.normalizedKey(\"Hello\\t\\nWorld\")\n        #expect(key1 == key2)\n        #expect(key2 == key3)\n    }\n\n    @Test func normalizedKey_trimmed() {\n        let key1 = ContentNormalizer.normalizedKey(\"Hello\")\n        let key2 = ContentNormalizer.normalizedKey(\"  Hello  \")\n        let key3 = ContentNormalizer.normalizedKey(\"\\nHello\\n\")\n        #expect(key1 == key2)\n        #expect(key2 == key3)\n    }\n\n    @Test func normalizedKey_urlQueryStripped() {\n        let key1 = ContentNormalizer.normalizedKey(\"Check https://example.com/page\")\n        let key2 = ContentNormalizer.normalizedKey(\"Check https://example.com/page?query=value\")\n        let key3 = ContentNormalizer.normalizedKey(\"Check https://example.com/page#anchor\")\n        #expect(key1 == key2)\n        #expect(key2 == key3)\n    }\n\n    @Test func normalizedKey_httpAndHttpsDistinct() {\n        // URL scheme is preserved\n        let key1 = ContentNormalizer.normalizedKey(\"http://example.com/page\")\n        let key2 = ContentNormalizer.normalizedKey(\"https://example.com/page\")\n        #expect(key1 != key2)\n    }\n\n    @Test func normalizedKey_differentContent() {\n        let key1 = ContentNormalizer.normalizedKey(\"Hello\")\n        let key2 = ContentNormalizer.normalizedKey(\"Goodbye\")\n        #expect(key1 != key2)\n    }\n\n    @Test func normalizedKey_returnsHashFormat() {\n        let key = ContentNormalizer.normalizedKey(\"Test content\")\n        #expect(key.hasPrefix(\"h:\"))\n        #expect(key.count == 18) // \"h:\" + 16 hex chars\n    }\n\n    @Test func normalizedKey_emptyContent() {\n        let key = ContentNormalizer.normalizedKey(\"\")\n        #expect(key.hasPrefix(\"h:\"))\n    }\n\n    @Test func normalizedKey_longContentTruncated() {\n        let longContent = String(repeating: \"a\", count: 10000)\n        let key1 = ContentNormalizer.normalizedKey(longContent)\n        let key2 = ContentNormalizer.normalizedKey(longContent + \"extra\")\n\n        // Both should be the same since content is truncated before hashing\n        #expect(key1 == key2)\n    }\n\n    @Test func normalizedKey_prefixLengthRespected() {\n        let content = \"Short\"\n        let key1 = ContentNormalizer.normalizedKey(content, prefixLength: 3)\n        let key2 = ContentNormalizer.normalizedKey(content, prefixLength: 100)\n\n        // Different prefix lengths may produce different keys\n        // \"sho\" vs \"short\"\n        #expect(key1 != key2)\n    }\n\n    @Test func normalizedKey_urlsInMiddleOfContent() {\n        let content1 = \"Check out https://example.com/path?query=1 for more info\"\n        let content2 = \"Check out https://example.com/path for more info\"\n        let key1 = ContentNormalizer.normalizedKey(content1)\n        let key2 = ContentNormalizer.normalizedKey(content2)\n        #expect(key1 == key2)\n    }\n\n    @Test func normalizedKey_multipleUrls() {\n        let content1 = \"Links: https://a.com?x=1 and http://b.com#y\"\n        let content2 = \"Links: https://a.com and http://b.com\"\n        let key1 = ContentNormalizer.normalizedKey(content1)\n        let key2 = ContentNormalizer.normalizedKey(content2)\n        #expect(key1 == key2)\n    }\n}\n\n// MARK: - Message Deduplication Service Tests\n\n@Suite(\"Message Deduplication Service\")\n@MainActor\nstruct MessageDeduplicationServiceTests {\n\n    // MARK: - Content Deduplication\n\n    @Test func recordContent_storesTimestamp() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n        let now = Date()\n\n        service.recordContent(\"Hello World\", timestamp: now)\n\n        let retrieved = service.contentTimestamp(for: \"Hello World\")\n        #expect(retrieved == now)\n    }\n\n    @Test func recordContent_updatesTimestamp() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n        let early = Date(timeIntervalSince1970: 1000)\n        let late = Date(timeIntervalSince1970: 2000)\n\n        service.recordContent(\"Hello World\", timestamp: early)\n        service.recordContent(\"Hello World\", timestamp: late)\n\n        let retrieved = service.contentTimestamp(for: \"Hello World\")\n        #expect(retrieved == late)\n    }\n\n    @Test func contentTimestamp_nilForUnseen() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n\n        let timestamp = service.contentTimestamp(for: \"Never seen\")\n        #expect(timestamp == nil)\n    }\n\n    @Test func recordContentKey_directKeyAccess() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n        let now = Date()\n        let key = service.normalizedContentKey(\"Test\")\n\n        service.recordContentKey(key, timestamp: now)\n\n        #expect(service.contentTimestamp(forKey: key) == now)\n    }\n\n    @Test func normalizedContentKey_consistentWithNormalizer() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n        let content = \"Hello World\"\n\n        let serviceKey = service.normalizedContentKey(content)\n        let normalizerKey = ContentNormalizer.normalizedKey(content)\n\n        #expect(serviceKey == normalizerKey)\n    }\n\n    // MARK: - Nostr Event Deduplication\n\n    @Test func recordNostrEvent_marksAsProcessed() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n\n        #expect(!service.hasProcessedNostrEvent(\"event123\"))\n\n        service.recordNostrEvent(\"event123\")\n\n        #expect(service.hasProcessedNostrEvent(\"event123\"))\n    }\n\n    @Test func hasProcessedNostrEvent_falseForUnseen() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n\n        #expect(!service.hasProcessedNostrEvent(\"never-seen\"))\n    }\n\n    @Test func nostrEvent_multipleEvents() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n\n        service.recordNostrEvent(\"event1\")\n        service.recordNostrEvent(\"event2\")\n        service.recordNostrEvent(\"event3\")\n\n        #expect(service.hasProcessedNostrEvent(\"event1\"))\n        #expect(service.hasProcessedNostrEvent(\"event2\"))\n        #expect(service.hasProcessedNostrEvent(\"event3\"))\n        #expect(!service.hasProcessedNostrEvent(\"event4\"))\n    }\n\n    // MARK: - Nostr ACK Deduplication\n\n    @Test func recordNostrAck_marksAsProcessed() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n        let ackKey = MessageDeduplicationService.ackKey(\n            messageId: \"msg123\",\n            ackType: \"delivered\",\n            senderPubkey: \"pubkey456\"\n        )\n\n        #expect(!service.hasProcessedNostrAck(ackKey))\n\n        service.recordNostrAck(ackKey)\n\n        #expect(service.hasProcessedNostrAck(ackKey))\n    }\n\n    @Test func ackKey_format() {\n        let key = MessageDeduplicationService.ackKey(\n            messageId: \"msg\",\n            ackType: \"read\",\n            senderPubkey: \"pub\"\n        )\n        #expect(key == \"msg:read:pub\")\n    }\n\n    @Test func ackKey_differentComponents() {\n        let key1 = MessageDeduplicationService.ackKey(messageId: \"a\", ackType: \"delivered\", senderPubkey: \"x\")\n        let key2 = MessageDeduplicationService.ackKey(messageId: \"a\", ackType: \"read\", senderPubkey: \"x\")\n        let key3 = MessageDeduplicationService.ackKey(messageId: \"b\", ackType: \"delivered\", senderPubkey: \"x\")\n\n        #expect(key1 != key2) // Different ackType\n        #expect(key1 != key3) // Different messageId\n    }\n\n    // MARK: - Clear Operations\n\n    @Test func clearAll_clearsEverything() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n        let now = Date()\n\n        service.recordContent(\"Hello\", timestamp: now)\n        service.recordNostrEvent(\"event1\")\n        service.recordNostrAck(\"ack1\")\n\n        service.clearAll()\n\n        #expect(service.contentTimestamp(for: \"Hello\") == nil)\n        #expect(!service.hasProcessedNostrEvent(\"event1\"))\n        #expect(!service.hasProcessedNostrAck(\"ack1\"))\n    }\n\n    @Test func clearNostrCaches_preservesContent() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n        let now = Date()\n\n        service.recordContent(\"Hello\", timestamp: now)\n        service.recordNostrEvent(\"event1\")\n        service.recordNostrAck(\"ack1\")\n\n        service.clearNostrCaches()\n\n        #expect(service.contentTimestamp(for: \"Hello\") == now) // Preserved\n        #expect(!service.hasProcessedNostrEvent(\"event1\")) // Cleared\n        #expect(!service.hasProcessedNostrAck(\"ack1\")) // Cleared\n    }\n\n    // MARK: - Capacity Tests\n\n    @Test func contentCache_respectsCapacity() {\n        let service = MessageDeduplicationService(contentCapacity: 3, nostrEventCapacity: 100)\n\n        service.recordContent(\"a\", timestamp: Date())\n        service.recordContent(\"b\", timestamp: Date())\n        service.recordContent(\"c\", timestamp: Date())\n        service.recordContent(\"d\", timestamp: Date())\n\n        // \"a\" should have been evicted\n        #expect(service.contentTimestamp(for: \"a\") == nil)\n        #expect(service.contentTimestamp(for: \"d\") != nil)\n    }\n\n    @Test func nostrEventCache_respectsCapacity() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 3)\n\n        service.recordNostrEvent(\"e1\")\n        service.recordNostrEvent(\"e2\")\n        service.recordNostrEvent(\"e3\")\n        service.recordNostrEvent(\"e4\")\n\n        // \"e1\" should have been evicted\n        #expect(!service.hasProcessedNostrEvent(\"e1\"))\n        #expect(service.hasProcessedNostrEvent(\"e4\"))\n    }\n\n    // MARK: - Integration Tests\n\n    @Test func realWorldDeduplication_similarMessages() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n        let now = Date()\n\n        // Record original message\n        service.recordContent(\"Check out https://example.com/page?ref=abc\", timestamp: now)\n\n        // Same URL with different query params should match\n        let timestamp = service.contentTimestamp(for: \"Check out https://example.com/page?ref=xyz\")\n        #expect(timestamp == now)\n    }\n\n    @Test func realWorldDeduplication_caseVariations() {\n        let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)\n        let now = Date()\n\n        service.recordContent(\"HELLO WORLD\", timestamp: now)\n\n        #expect(service.contentTimestamp(for: \"hello world\") == now)\n        #expect(service.contentTimestamp(for: \"Hello World\") == now)\n    }\n\n    // MARK: - Thread Safety Tests (via @MainActor enforcement)\n\n    @Test(\"Concurrent content recording is safe via MainActor\")\n    func concurrentContentRecording() async {\n        let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)\n        let iterations = 100\n\n        // All operations run on MainActor due to @MainActor annotation\n        // This test verifies the pattern works correctly\n        await withTaskGroup(of: Void.self) { group in\n            for i in 0..<iterations {\n                group.addTask { @MainActor in\n                    service.recordContent(\"Message \\(i)\", timestamp: Date())\n                }\n            }\n        }\n\n        // Verify some entries were recorded\n        #expect(service.contentTimestamp(for: \"Message 0\") != nil)\n        #expect(service.contentTimestamp(for: \"Message 99\") != nil)\n    }\n\n    @Test(\"Concurrent Nostr event recording is safe via MainActor\")\n    func concurrentNostrEventRecording() async {\n        let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)\n        let iterations = 100\n\n        await withTaskGroup(of: Void.self) { group in\n            for i in 0..<iterations {\n                group.addTask { @MainActor in\n                    service.recordNostrEvent(\"event_\\(i)\")\n                }\n            }\n        }\n\n        // Verify events were recorded\n        #expect(service.hasProcessedNostrEvent(\"event_0\"))\n        #expect(service.hasProcessedNostrEvent(\"event_99\"))\n    }\n\n    @Test(\"Mixed concurrent operations are safe via MainActor\")\n    func concurrentMixedOperations() async {\n        let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)\n        let iterations = 50\n\n        await withTaskGroup(of: Void.self) { group in\n            // Content recording tasks\n            for i in 0..<iterations {\n                group.addTask { @MainActor in\n                    service.recordContent(\"Content \\(i)\", timestamp: Date())\n                }\n            }\n\n            // Event recording tasks\n            for i in 0..<iterations {\n                group.addTask { @MainActor in\n                    service.recordNostrEvent(\"event_\\(i)\")\n                }\n            }\n\n            // ACK recording tasks\n            for i in 0..<iterations {\n                group.addTask { @MainActor in\n                    service.recordNostrAck(\"ack_\\(i)\")\n                }\n            }\n\n            // Read tasks\n            for i in 0..<iterations {\n                group.addTask { @MainActor in\n                    _ = service.contentTimestamp(for: \"Content \\(i)\")\n                    _ = service.hasProcessedNostrEvent(\"event_\\(i)\")\n                    _ = service.hasProcessedNostrAck(\"ack_\\(i)\")\n                }\n            }\n        }\n\n        // If we reach here without crashes, the test passes\n    }\n}\n\n// MARK: - LRU Cache Thread Safety Tests\n\n@Suite(\"LRU Cache Thread Safety\")\n@MainActor\nstruct LRUCacheThreadSafetyTests {\n\n    @Test(\"Concurrent cache access is safe via MainActor\")\n    func concurrentCacheAccess() async {\n        let cache = LRUDeduplicationCache<Int>(capacity: 500)\n        let iterations = 100\n\n        await withTaskGroup(of: Void.self) { group in\n            // Write tasks\n            for i in 0..<iterations {\n                group.addTask { @MainActor in\n                    cache.record(\"key_\\(i)\", value: i)\n                }\n            }\n\n            // Read tasks\n            for i in 0..<iterations {\n                group.addTask { @MainActor in\n                    _ = cache.contains(\"key_\\(i)\")\n                    _ = cache.value(for: \"key_\\(i)\")\n                }\n            }\n        }\n\n        // Verify cache is in consistent state\n        #expect(cache.count <= 500) // Respects capacity\n    }\n\n    @Test(\"Cache eviction under concurrent load is safe\")\n    func cacheEvictionUnderLoad() async {\n        let cache = LRUDeduplicationCache<Int>(capacity: 10)\n        let iterations = 100\n\n        await withTaskGroup(of: Void.self) { group in\n            for i in 0..<iterations {\n                group.addTask { @MainActor in\n                    cache.record(\"key_\\(i)\", value: i)\n                }\n            }\n        }\n\n        // Cache should maintain its capacity constraint\n        #expect(cache.count == 10)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/MessageFormattingEngineTests.swift",
    "content": "//\n// MessageFormattingEngineTests.swift\n// bitchatTests\n//\n// Tests for MessageFormattingEngine regex patterns and utility functions.\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Testing\nimport Foundation\nimport SwiftUI\n@testable import bitchat\n\nstruct MessageFormattingEngineTests {\n    // MARK: - Formatting Behavior Tests\n\n    @MainActor\n    @Test func formatMessage_regularMessageFormatsHeaderContentAndTimestamp() {\n        let senderPeerID = PeerID(str: \"abcdef1234567890\")\n        let context = MockMessageFormattingContext(\n            nickname: \"carol\",\n            peerURLs: [senderPeerID: URL(string: \"https://example.com/peers/alice\")!]\n        )\n        let message = BitchatMessage(\n            id: \"message-1\",\n            sender: \"alice#a1b2\",\n            content: \"hello #mesh https://example.com\",\n            timestamp: Date(timeIntervalSince1970: 1_700_000_000),\n            isRelay: false,\n            senderPeerID: senderPeerID\n        )\n\n        let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)\n\n        #expect(String(formatted.characters) == \"<@alice#a1b2> hello #mesh https://example.com [\\(message.formattedTimestamp)]\")\n        #expect(message.getCachedFormattedText(isDark: false, isSelf: false) != nil)\n    }\n\n    @MainActor\n    @Test func formatMessage_systemMessageUsesSystemLayout() {\n        let context = MockMessageFormattingContext(nickname: \"carol\")\n        let message = BitchatMessage(\n            id: \"system-1\",\n            sender: \"system\",\n            content: \"connected\",\n            timestamp: Date(timeIntervalSince1970: 1_700_000_123),\n            isRelay: false\n        )\n\n        let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .dark)\n\n        #expect(String(formatted.characters) == \"* connected * [\\(message.formattedTimestamp)]\")\n        #expect(message.getCachedFormattedText(isDark: true, isSelf: false) != nil)\n    }\n\n    @MainActor\n    @Test func formatMessage_longSelfMessageFallsBackToPlainContentPath() {\n        let context = MockMessageFormattingContext(\n            nickname: \"me\",\n            selfMessageIDs: [\"self-1\"]\n        )\n        let longContent = String(repeating: \"a\", count: 4_500)\n        let message = BitchatMessage(\n            id: \"self-1\",\n            sender: \"me#cafe\",\n            content: longContent,\n            timestamp: Date(timeIntervalSince1970: 1_700_000_456),\n            isRelay: false\n        )\n\n        let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)\n\n        #expect(String(formatted.characters) == \"<@me#cafe> \\(longContent) [\\(message.formattedTimestamp)]\")\n        #expect(message.getCachedFormattedText(isDark: false, isSelf: true) != nil)\n    }\n\n    @MainActor\n    @Test func formatMessage_mentionsAreRenderedThroughMentionFormatter() {\n        let context = MockMessageFormattingContext(nickname: \"carol\")\n        let message = BitchatMessage(\n            id: \"message-mention\",\n            sender: \"alice\",\n            content: \"hi @bob#a1b2\",\n            timestamp: Date(timeIntervalSince1970: 1_700_000_789),\n            isRelay: false\n        )\n\n        let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)\n\n        #expect(String(formatted.characters) == \"<@alice> hi bob#a1b2 [\\(message.formattedTimestamp)]\")\n    }\n\n    @MainActor\n    @Test func formatHeader_formatsNormalAndSystemSenders() {\n        let context = MockMessageFormattingContext(nickname: \"carol\")\n        let normalMessage = BitchatMessage(\n            id: \"header-1\",\n            sender: \"alice#a1b2\",\n            content: \"hello\",\n            timestamp: Date(timeIntervalSince1970: 1_700_001_000),\n            isRelay: false\n        )\n        let systemMessage = BitchatMessage(\n            id: \"header-2\",\n            sender: \"system\",\n            content: \"notice\",\n            timestamp: Date(timeIntervalSince1970: 1_700_001_111),\n            isRelay: false\n        )\n\n        let normalHeader = MessageFormattingEngine.formatHeader(normalMessage, context: context, colorScheme: .light)\n        let systemHeader = MessageFormattingEngine.formatHeader(systemMessage, context: context, colorScheme: .dark)\n\n        #expect(String(normalHeader.characters) == \"<@alice#a1b2> \")\n        #expect(String(systemHeader.characters) == \"system\")\n    }\n\n    // MARK: - Mention Extraction Tests\n\n    @Test func extractMentions_singleMention() {\n        let content = \"Hello @alice how are you?\"\n        let mentions = MessageFormattingEngine.extractMentions(from: content)\n        #expect(mentions == [\"alice\"])\n    }\n\n    @Test func extractMentions_multipleMentions() {\n        let content = \"@alice and @bob are chatting with @charlie\"\n        let mentions = MessageFormattingEngine.extractMentions(from: content)\n        #expect(mentions.count == 3)\n        #expect(mentions.contains(\"alice\"))\n        #expect(mentions.contains(\"bob\"))\n        #expect(mentions.contains(\"charlie\"))\n    }\n\n    @Test func extractMentions_mentionWithSuffix() {\n        let content = \"Hey @alice#a1b2 check this out\"\n        let mentions = MessageFormattingEngine.extractMentions(from: content)\n        #expect(mentions == [\"alice#a1b2\"])\n    }\n\n    @Test func extractMentions_noMentions() {\n        let content = \"Just a regular message with no mentions\"\n        let mentions = MessageFormattingEngine.extractMentions(from: content)\n        #expect(mentions.isEmpty)\n    }\n\n    @Test func extractMentions_unicodeNickname() {\n        let content = \"Hello @日本語 and @émile\"\n        let mentions = MessageFormattingEngine.extractMentions(from: content)\n        #expect(mentions.count == 2)\n        #expect(mentions.contains(\"日本語\"))\n        #expect(mentions.contains(\"émile\"))\n    }\n\n    @Test func extractMentions_mentionWithUnderscore() {\n        let content = \"Thanks @user_name_123\"\n        let mentions = MessageFormattingEngine.extractMentions(from: content)\n        #expect(mentions == [\"user_name_123\"])\n    }\n\n    @Test func extractMentions_emailNotCaptured() {\n        // Email addresses should not be captured as mentions\n        let content = \"Contact me at test@example.com\"\n        let mentions = MessageFormattingEngine.extractMentions(from: content)\n        // The regex will capture \"example\" after @ in email - this is expected behavior\n        // as the regex doesn't distinguish email addresses\n        #expect(mentions.count == 1)\n    }\n\n    // MARK: - Cashu Token Detection Tests\n\n    @Test func containsCashuToken_validTokenA() {\n        let content = \"Here's a token: cashuAeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i\"\n        #expect(MessageFormattingEngine.containsCashuToken(content))\n    }\n\n    @Test func containsCashuToken_validTokenB() {\n        let content = \"Payment: cashuBeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i\"\n        #expect(MessageFormattingEngine.containsCashuToken(content))\n    }\n\n    @Test func containsCashuToken_noToken() {\n        let content = \"Just a regular message about cashews\"\n        #expect(!MessageFormattingEngine.containsCashuToken(content))\n    }\n\n    @Test func containsCashuToken_tooShort() {\n        let content = \"Invalid: cashuAshort\"\n        #expect(!MessageFormattingEngine.containsCashuToken(content))\n    }\n\n    // MARK: - Regex Pattern Tests\n\n    @Test func hashtagPattern_standaloneHashtag() {\n        let content = \"#bitcoin is great\"\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)\n        #expect(matches.count == 1)\n    }\n\n    @Test func hashtagPattern_multipleHashtags() {\n        let content = \"#bitcoin #lightning #nostr\"\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)\n        #expect(matches.count == 3)\n    }\n\n    @Test func hashtagPattern_hashInMiddleOfWord() {\n        let content = \"test#notahashtag\"\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)\n        // This will match because the regex doesn't check for word boundaries\n        #expect(matches.count == 1)\n    }\n\n    @Test func bolt11Pattern_mainnet() {\n        let content = \"Pay this: lnbc10u1pjexampleinvoice0000000000000000000000000000000000000000000\"\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range)\n        #expect(matches.count == 1)\n    }\n\n    @Test func bolt11Pattern_testnet() {\n        let content = \"Test: lntb10u1pjexampleinvoice0000000000000000000000000000000000000000000\"\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range)\n        #expect(matches.count == 1)\n    }\n\n    @Test func lnurlPattern_valid() {\n        let content = \"LNURL: lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserx\"\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        let matches = MessageFormattingEngine.Patterns.lnurl.matches(in: content, options: [], range: range)\n        #expect(matches.count == 1)\n    }\n\n    @Test func lightningSchemePattern_valid() {\n        let content = \"Click: lightning:lnbc10u1example\"\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        let matches = MessageFormattingEngine.Patterns.lightningScheme.matches(in: content, options: [], range: range)\n        #expect(matches.count == 1)\n    }\n\n    @Test func cashuPattern_valid() {\n        let content = \"Token: cashuAeyJwcm9vZnMiOlt7ImlkIjoiMDAwMDAwMDAwMDAwMDAwMCJ9XX0=\"\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        let matches = MessageFormattingEngine.Patterns.cashu.matches(in: content, options: [], range: range)\n        #expect(matches.count == 1)\n    }\n\n    // MARK: - URL Detection Tests\n\n    @Test func linkDetector_httpURL() {\n        let content = \"Check out http://example.com\"\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []\n        #expect(matches.count == 1)\n    }\n\n    @Test func linkDetector_httpsURL() {\n        let content = \"Visit https://example.com/path?query=value\"\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []\n        #expect(matches.count == 1)\n    }\n\n    @Test func linkDetector_multipleURLs() {\n        let content = \"See https://a.com and http://b.com\"\n        let nsContent = content as NSString\n        let range = NSRange(location: 0, length: nsContent.length)\n        let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []\n        #expect(matches.count == 2)\n    }\n\n    // MARK: - String Extension Tests\n\n    @Test func splitSuffix_withSuffix() {\n        let name = \"alice#a1b2\"\n        let (base, suffix) = name.splitSuffix()\n        #expect(base == \"alice\")\n        #expect(suffix == \"#a1b2\")\n    }\n\n    @Test func splitSuffix_withoutSuffix() {\n        let name = \"alice\"\n        let (base, suffix) = name.splitSuffix()\n        #expect(base == \"alice\")\n        #expect(suffix == \"\")\n    }\n\n    @Test func splitSuffix_withAtPrefix() {\n        let name = \"@alice#a1b2\"\n        let (base, suffix) = name.splitSuffix()\n        #expect(base == \"alice\")\n        #expect(suffix == \"#a1b2\")\n    }\n\n    @Test func hasVeryLongToken_noLongToken() {\n        let content = \"Short words only here\"\n        #expect(!content.hasVeryLongToken(threshold: 50))\n    }\n\n    @Test func hasVeryLongToken_withLongToken() {\n        let longToken = String(repeating: \"a\", count: 100)\n        let content = \"Here is a \\(longToken) token\"\n        #expect(content.hasVeryLongToken(threshold: 50))\n    }\n\n    @Test func hasVeryLongToken_exactThreshold() {\n        let exactToken = String(repeating: \"a\", count: 50)\n        let content = \"Token: \\(exactToken)\"\n        // Exactly at threshold DOES trigger (uses >= comparison)\n        #expect(content.hasVeryLongToken(threshold: 50))\n    }\n}\n\n@MainActor\nprivate final class MockMessageFormattingContext: MessageFormattingContext {\n    let nickname: String\n    private let selfMessageIDs: Set<String>\n    private let peerURLs: [PeerID: URL]\n\n    init(\n        nickname: String,\n        selfMessageIDs: Set<String> = [],\n        peerURLs: [PeerID: URL] = [:]\n    ) {\n        self.nickname = nickname\n        self.selfMessageIDs = selfMessageIDs\n        self.peerURLs = peerURLs\n    }\n\n    func isSelfMessage(_ message: BitchatMessage) -> Bool {\n        selfMessageIDs.contains(message.id)\n    }\n\n    func senderColor(for message: BitchatMessage, isDark: Bool) -> Color {\n        .red\n    }\n\n    func peerURL(for peerID: PeerID) -> URL? {\n        peerURLs[peerID]\n    }\n}\n"
  },
  {
    "path": "bitchatTests/MimeTypeTests.swift",
    "content": "//\n// MimeTypeTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\n// MARK: - MimeType Mapping and Signature Tests\n\nstruct MimeTypeTests {\n\n    // MARK: MIME → Enum Parsing + Default Extension\n    @Test(arguments: [\n        (\"image/jpeg\", MimeType.jpeg, \"jpg\"),\n        (\"image/jpg\", MimeType.jpeg, \"jpg\"),\n        (\"image/png\", MimeType.png, \"png\"),\n        (\"image/gif\", MimeType.gif, \"gif\"),\n        (\"image/webp\", MimeType.webp, \"webp\"),\n        (\"audio/mp4\", MimeType.mp4Audio, \"m4a\"),\n        (\"audio/m4a\", MimeType.m4a, \"m4a\"),\n        (\"audio/aac\", MimeType.aac, \"m4a\"),\n        (\"audio/mpeg\", MimeType.mpeg, \"mp3\"),\n        (\"audio/mp3\", MimeType.mp3, \"mp3\"),\n        (\"audio/wav\", MimeType.wav, \"wav\"),\n        (\"audio/x-wav\", MimeType.xWav, \"wav\"),\n        (\"audio/ogg\", MimeType.ogg, \"ogg\"),\n        (\"application/pdf\", MimeType.pdf, \"pdf\"),\n        (\"application/octet-stream\", MimeType.octetStream, \"bin\")\n    ])\n    func mimeTypeParsingAndExtensions(\n        mimeString: String,\n        expectedType: MimeType,\n        expectedExt: String\n    ) throws {\n        guard let mime = MimeType(mimeString) else {\n            Issue.record(\"Failed to parse \\(mimeString)\")\n            return\n        }\n\n        #expect(mime == expectedType, \"Expected \\(expectedType) for \\(mimeString)\")\n        #expect(mime.mimeString == expectedType.mimeString)\n        #expect(mime.defaultExtension == expectedExt)\n        #expect(mime.isAllowed)\n    }\n\n    // MARK: - File Signature Validation\n    @Test(arguments: [\n        // === Image types ===\n        (MimeType.jpeg, [0xFF, 0xD8, 0xFF]),\n        (MimeType.png,  [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),\n        (MimeType.gif,  [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // \"GIF89a\"\n        (MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,\n                         0x57, 0x45, 0x42, 0x50]),             // \"RIFF....WEBP\"\n\n        // === Audio types ===\n        (MimeType.mp3,  [0x49, 0x44, 0x33]),                   // \"ID3\"\n        (MimeType.wav,  [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,\n                         0x57, 0x41, 0x56, 0x45]),             // \"RIFF....WAVE\"\n        (MimeType.ogg,  [0x4F, 0x67, 0x67, 0x53]),             // \"OggS\"\n\n        // === Application types ===\n        (MimeType.pdf,  [0x25, 0x50, 0x44, 0x46])              // \"%PDF\"\n    ])\n    func validSignatures(mime: MimeType, bytes: [UInt8]) throws {\n        let data = Data(bytes)\n        #expect(mime.matches(data: data),\n                \"Expected \\(mime.mimeString) to match its signature\")\n    }\n\n    // MARK: - Negative Tests\n    @Test func invalidDataDoesNotMatch() throws {\n        let badData = Data(repeating: 0x00, count: 16)\n        for mime in MimeType.allCases where mime != .octetStream {\n            #expect(!mime.matches(data: badData),\n                    \"Unexpectedly matched \\(mime.mimeString) with zeroed data\")\n        }\n    }\n\n    // MARK: - Octet-stream (generic binary)\n    @Test func octetStreamAlwaysMatches() throws {\n        let randomData = Data([0x00, 0x11, 0x22, 0x33])\n        #expect(MimeType.octetStream.matches(data: randomData),\n                \"application/octet-stream should always be considered valid\")\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Mocks/MockBLEBus.swift",
    "content": "//\n// MockBLEBus.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n@testable import bitchat\n\nfinal class MockBLEBus {\n    private var registry: [PeerID: MockBLEService] = [:]\n    private var adjacency: [PeerID: Set<PeerID>] = [:]\n\n    // Enable automatic flooding for public messages in integration tests only\n    let autoFloodEnabled: Bool\n    \n    init(autoFloodEnabled: Bool = false) {\n        self.autoFloodEnabled = autoFloodEnabled\n    }\n\n    func register(_ service: MockBLEService, for peerID: PeerID) {\n        registry[peerID] = service\n        if adjacency[peerID] == nil { adjacency[peerID] = [] }\n    }\n\n    func connect(_ a: PeerID, _ b: PeerID) {\n        var setA = adjacency[a] ?? []\n        setA.insert(b)\n        adjacency[a] = setA\n        var setB = adjacency[b] ?? []\n        setB.insert(a)\n        adjacency[b] = setB\n    }\n\n    func disconnect(_ a: PeerID, _ b: PeerID) {\n        if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }\n        if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }\n    }\n\n    func neighbors(of peerID: PeerID) -> [MockBLEService] {\n        let ids = adjacency[peerID] ?? []\n        let result = ids.compactMap { registry[$0] }\n        return result\n    }\n\n    func isDirectNeighbor(_ a: PeerID, _ b: PeerID) -> Bool {\n        let res = adjacency[a]?.contains(b) ?? false\n        return res\n    }\n\n    func service(for peerID: PeerID) -> MockBLEService? {\n        let svc = registry[peerID]\n        return svc\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Mocks/MockBLEService.swift",
    "content": "//\n// MockBLEService.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\nimport CoreBluetooth\n@testable import bitchat\n\n/// In-memory BLE test harness used by E2E/Integration tests.\n///\n/// Design:\n/// - Global `registry` maps `peerID` -> service instance, and `adjacency` tracks\n///   simulated connections between peers. Tests call `simulateConnectedPeer` /\n///   `simulateDisconnectedPeer` to manage topology.\n/// - `resetTestBus()` clears global state and is called in test `setUp()`.\n/// - `messageDeliveryHandler` and `packetDeliveryHandler` let tests observe messages/packets\n///   as they flow, enabling scenarios like manual encryption/relay.\n/// - A thread-safe `seenMessageIDs` set prevents double-delivery races during flooding.\n///\n/// Flooding:\n/// - `autoFloodEnabled` is disabled by default; Integration tests enable it in `setUp()` to\n///   simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit\n///   relays when needed.\nfinal class MockBLEService: NSObject {\n    private let bus: MockBLEBus\n    \n    // MARK: - Properties matching BLEService\n    \n    weak var delegate: BitchatDelegate?\n    var myPeerID = PeerID(str: \"MOCK1234\")\n    var myNickname: String = \"MockUser\"\n    \n    private let mockKeychain = MockKeychain()\n    \n    // Test-specific properties\n    var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = []\n    var sentPackets: [BitchatPacket] = []\n    var connectedPeers: Set<PeerID> = []\n    var messageDeliveryHandler: ((BitchatMessage) -> Void)?\n    var packetDeliveryHandler: ((BitchatPacket) -> Void)?\n    \n    // Compatibility properties for old tests\n    var mockNickname: String {\n        get { return myNickname }\n        set { myNickname = newValue }\n    }\n    \n    var nickname: String {\n        return myNickname\n    }\n    \n    var peerID: PeerID {\n        return myPeerID\n    }\n    \n    // MARK: - Initialization\n    \n    init(bus: MockBLEBus) {\n        self.bus = bus\n    }\n    \n    // MARK: - Methods matching BLEService\n    \n    func setNickname(_ nickname: String) {\n        self.myNickname = nickname\n    }\n    \n    // MARK: - In-memory test bus (for E2E/Integration)\n\n    /// Registers this instance on first use.\n    private func registerIfNeeded() {\n        bus.register(self, for: myPeerID)\n    }\n\n    /// Returns adjacent neighbors based on the current simulated topology.\n    private func neighbors() -> [MockBLEService] {\n        bus.neighbors(of: myPeerID)\n    }\n\n    func startServices() {\n        // Mock implementation - do nothing\n    }\n    \n    func stopServices() {\n        // Mock implementation - do nothing\n    }\n    \n    func isPeerConnected(_ peerID: PeerID) -> Bool {\n        return connectedPeers.contains(peerID)\n    }\n\n    func peerNickname(peerID: String) -> String? {\n        \"MockPeer_\\(peerID)\"\n    }\n\n    func getPeerNicknames() -> [PeerID: String] {\n        var nicknames: [PeerID: String] = [:]\n        for peer in connectedPeers {\n            nicknames[peer] = \"MockPeer_\\(peer)\"\n        }\n        return nicknames\n    }\n    \n    func getPeers() -> [PeerID: String] {\n        return getPeerNicknames()\n    }\n\n    /// Keep local echo synchronous so Swift Testing confirmations observe it deterministically.\n    private func deliverLocalEcho(_ message: BitchatMessage) {\n        delegate?.didReceiveMessage(message)\n    }\n    \n    func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {\n        let message = BitchatMessage(\n            id: messageID ?? UUID().uuidString,\n            sender: myNickname,\n            content: content,\n            timestamp: timestamp ?? Date(),\n            isRelay: false,\n            originalSender: nil,\n            isPrivate: recipientID != nil,\n            recipientNickname: nil,\n            senderPeerID: myPeerID,\n            mentions: mentions.isEmpty ? nil : mentions\n        )\n        \n        if let payload = message.toBinaryPayload() {\n            let packet = BitchatPacket(\n                type: 0x01,\n                senderID: myPeerID.id.data(using: .utf8)!,\n                recipientID: recipientID?.data(using: .utf8),\n                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                payload: payload,\n                signature: nil,\n                ttl: 3\n            )\n            \n            sentMessages.append((message, packet))\n            sentPackets.append(packet)\n            \n            deliverLocalEcho(message)\n            \n            // Surface raw packet to tests that intercept/relay/encrypt\n            packetDeliveryHandler?(packet)\n\n            // Deliver public messages to adjacent peers via bus\n            if recipientID == nil {\n                for neighbor in neighbors() {\n                    neighbor.simulateIncomingPacket(packet)\n                }\n            }\n        }\n    }\n\n    func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {\n        // Tests currently ignore file transfer flows; keep stub for protocol conformance.\n    }\n\n    func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {\n        // Tests currently ignore file transfer flows; keep stub for protocol conformance.\n    }\n\n    func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) {\n        let message = BitchatMessage(\n            id: messageID,\n            sender: myNickname,\n            content: content,\n            timestamp: Date(),\n            isRelay: false,\n            originalSender: nil,\n            isPrivate: true,\n            recipientNickname: recipientNickname,\n            senderPeerID: myPeerID,\n            mentions: nil\n        )\n        \n        if let payload = message.toBinaryPayload() {\n            let packet = BitchatPacket(\n                type: 0x01,\n                senderID: myPeerID.id.data(using: .utf8)!,\n                recipientID: recipientPeerID.id.data(using: .utf8)!,\n                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n                payload: payload,\n                signature: nil,\n                ttl: 3\n            )\n            \n            sentMessages.append((message, packet))\n            sentPackets.append(packet)\n            \n            deliverLocalEcho(message)\n            \n            // Surface raw packet to tests that intercept/relay/encrypt\n            packetDeliveryHandler?(packet)\n\n            // If directly connected to recipient, deliver only to them.\n            if bus.isDirectNeighbor(myPeerID, recipientPeerID),\n               let target = bus.service(for: recipientPeerID) {\n                target.simulateIncomingPacket(packet)\n            } else {\n                // Not directly connected: deliver to neighbors for relay\n                for neighbor in neighbors() where neighbor.peerID != recipientPeerID {\n                    neighbor.simulateIncomingPacket(packet)\n                }\n            }\n        }\n    }\n    \n    func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {\n        // Mock implementation\n    }\n    \n    func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {\n        // Mock implementation\n    }\n    \n    func sendBroadcastAnnounce() {\n        // Mock implementation\n    }\n    \n    func getPeerFingerprint(_ peerID: String) -> String? {\n        return nil\n    }\n    \n    func getNoiseSessionState(for peerID: String) -> LazyHandshakeState {\n        return .none\n    }\n    \n    func triggerHandshake(with peerID: String) {\n        // Mock implementation\n    }\n    \n    func emergencyDisconnectAll() {\n        connectedPeers.removeAll()\n        delegate?.didUpdatePeerList([])\n    }\n    \n    func getNoiseService() -> NoiseEncryptionService {\n        return NoiseEncryptionService(keychain: mockKeychain)\n    }\n    \n    func getFingerprint(for peerID: String) -> String? {\n        return nil\n    }\n    \n    // MARK: - Test Helper Methods\n    \n    func simulateConnectedPeer(_ peerID: PeerID) {\n        registerIfNeeded()\n        bus.connect(myPeerID, peerID)\n        connectedPeers.insert(peerID)\n        delegate?.didConnectToPeer(peerID)\n        delegate?.didUpdatePeerList(Array(connectedPeers))\n    }\n    \n    func simulateDisconnectedPeer(_ peerID: PeerID) {\n        bus.disconnect(myPeerID, peerID)\n        connectedPeers.remove(peerID)\n        delegate?.didDisconnectFromPeer(peerID)\n        delegate?.didUpdatePeerList(Array(connectedPeers))\n    }\n    \n    func simulateIncomingMessage(_ message: BitchatMessage) {\n        delegate?.didReceiveMessage(message)\n        // Also surface via test handler for E2E/Integration\n        messageDeliveryHandler?(message)\n    }\n    \n    private var seenMessageIDs: Set<String> = []\n    private let seenLock = NSLock()\n\n    func simulateIncomingPacket(_ packet: BitchatPacket) {\n        // Process through the actual handling logic\n        if let message = BitchatMessage(packet.payload) {\n            var shouldDeliver = false\n            seenLock.lock()\n            if !seenMessageIDs.contains(message.id) {\n                seenMessageIDs.insert(message.id)\n                shouldDeliver = true\n            }\n            seenLock.unlock()\n            if shouldDeliver {\n                delegate?.didReceiveMessage(message)\n                // Also surface via test handler for E2E/Integration\n                messageDeliveryHandler?(message)\n                // Optional flooding for integration-style broadcast tests.\n                // When enabled, propagate a public broadcast across the entire connected\n                // component regardless of the original TTL to better emulate large-network\n                // broadcast expectations. De-duplication via seenMessageIDs prevents loops.\n                if bus.autoFloodEnabled,\n                   packet.recipientID == nil,\n                   !message.isPrivate {\n                    let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0\n                    for neighbor in neighbors() {\n                        // Avoid immediate echo loopback to sender if known\n                        if let sender = message.senderPeerID, sender == neighbor.peerID { continue }\n                        var relay = packet\n                        relay.ttl = nextTTL\n                        neighbor.simulateIncomingPacket(relay)\n                    }\n                }\n            }\n        }\n        packetDeliveryHandler?(packet)\n    }\n    \n    func getConnectedPeers() -> [PeerID] {\n        return Array(connectedPeers)\n    }\n    \n    // MARK: - Compatibility methods for old tests\n    \n    func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String? = nil) {\n        sendPrivateMessage(content, to: recipientPeerID, recipientNickname: recipientNickname, messageID: messageID ?? UUID().uuidString)\n    }\n}\n\n// Backward compatibility for older tests\ntypealias MockSimplifiedBluetoothService = MockBLEService\n\n// MARK: - Helpers\n\nextension MockBLEService {\n    convenience init(peerID: PeerID, nickname: String, bus: MockBLEBus) {\n        self.init(bus: bus)\n        myPeerID = peerID\n        mockNickname = nickname\n    }\n\n    func simulateConnection(with otherPeer: MockBLEService) {\n        simulateConnectedPeer(otherPeer.myPeerID)\n        otherPeer.simulateConnectedPeer(myPeerID)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Mocks/MockIdentityManager.swift",
    "content": "//\n// MockIdentityManager.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n@testable import bitchat\n\nfinal class MockIdentityManager: SecureIdentityStateManagerProtocol {\n    private let keychain: KeychainManagerProtocol\n    private var blockedFingerprints: Set<String> = []\n    private var blockedNostrPubkeys: Set<String> = []\n    private var socialIdentities: [String: SocialIdentity] = [:]\n    \n    init(_ keychain: KeychainManagerProtocol) {\n        self.keychain = keychain\n    }\n    \n    func loadIdentityCache() {}\n    \n    func saveIdentityCache() {}\n    \n    func forceSave() {}\n    \n    func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {\n        socialIdentities[fingerprint]\n    }\n    \n    func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}\n    \n    func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {\n        []\n    }\n    \n    func updateSocialIdentity(_ identity: SocialIdentity) {\n        socialIdentities[identity.fingerprint] = identity\n        if identity.isBlocked {\n            blockedFingerprints.insert(identity.fingerprint)\n        } else {\n            blockedFingerprints.remove(identity.fingerprint)\n        }\n    }\n    \n    func getFavorites() -> Set<String> {\n        Set()\n    }\n    \n    func setFavorite(_ fingerprint: String, isFavorite: Bool) {}\n    \n    func isFavorite(fingerprint: String) -> Bool {\n        false\n    }\n    \n    func isBlocked(fingerprint: String) -> Bool {\n        blockedFingerprints.contains(fingerprint) || socialIdentities[fingerprint]?.isBlocked == true\n    }\n    \n    func setBlocked(_ fingerprint: String, isBlocked: Bool) {\n        if var identity = socialIdentities[fingerprint] {\n            identity.isBlocked = isBlocked\n            socialIdentities[fingerprint] = identity\n        } else {\n            let identity = SocialIdentity(\n                fingerprint: fingerprint,\n                localPetname: nil,\n                claimedNickname: \"\",\n                trustLevel: .unknown,\n                isFavorite: false,\n                isBlocked: isBlocked,\n                notes: nil\n            )\n            socialIdentities[fingerprint] = identity\n        }\n        if isBlocked {\n            blockedFingerprints.insert(fingerprint)\n        } else {\n            blockedFingerprints.remove(fingerprint)\n        }\n    }\n    \n    func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {\n        blockedNostrPubkeys.contains(pubkeyHexLowercased)\n    }\n    \n    func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {\n        if isBlocked {\n            blockedNostrPubkeys.insert(pubkeyHexLowercased)\n        } else {\n            blockedNostrPubkeys.remove(pubkeyHexLowercased)\n        }\n    }\n    \n    func getBlockedNostrPubkeys() -> Set<String> {\n        blockedNostrPubkeys\n    }\n    \n    func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}\n    \n    func updateHandshakeState(peerID: PeerID, state: HandshakeState) {}\n    \n    func clearAllIdentityData() {}\n    \n    func removeEphemeralSession(peerID: PeerID) {}\n    \n    func setVerified(fingerprint: String, verified: Bool) {}\n    \n    func isVerified(fingerprint: String) -> Bool {\n        true\n    }\n    \n    func getVerifiedFingerprints() -> Set<String> {\n        Set()\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Mocks/MockKeychain.swift",
    "content": "//\n// MockKeychain.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n@testable import bitchat\n\nfinal class MockKeychain: KeychainManagerProtocol {\n    private var storage: [String: Data] = [:]\n    private var serviceStorage: [String: [String: Data]] = [:]\n\n    // BCH-01-009: Configurable error simulation for testing\n    var simulatedReadError: KeychainReadResult?\n    var simulatedSaveError: KeychainSaveResult?\n\n    func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {\n        storage[key] = keyData\n        return true\n    }\n\n    func getIdentityKey(forKey key: String) -> Data? {\n        storage[key]\n    }\n\n    func deleteIdentityKey(forKey key: String) -> Bool {\n        storage.removeValue(forKey: key)\n        return true\n    }\n\n    func deleteAllKeychainData() -> Bool {\n        storage.removeAll()\n        serviceStorage.removeAll()\n        return true\n    }\n\n    func secureClear(_ data: inout Data) {\n        data = Data()\n    }\n\n    func secureClear(_ string: inout String) {\n        string = \"\"\n    }\n\n    func verifyIdentityKeyExists() -> Bool {\n        storage[\"identity_noiseStaticKey\"] != nil\n    }\n\n    // BCH-01-009: New methods with proper error classification\n    func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {\n        if let simulated = simulatedReadError {\n            return simulated\n        }\n        if let data = storage[key] {\n            return .success(data)\n        }\n        return .itemNotFound\n    }\n\n    func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {\n        if let simulated = simulatedSaveError {\n            return simulated\n        }\n        storage[key] = keyData\n        return .success\n    }\n\n    // MARK: - Generic Data Storage (consolidated from KeychainHelper)\n\n    func save(key: String, data: Data, service: String, accessible: CFString?) {\n        if serviceStorage[service] == nil {\n            serviceStorage[service] = [:]\n        }\n        serviceStorage[service]?[key] = data\n    }\n\n    func load(key: String, service: String) -> Data? {\n        serviceStorage[service]?[key]\n    }\n\n    func delete(key: String, service: String) {\n        serviceStorage[service]?.removeValue(forKey: key)\n    }\n}\n\n/// Typealias for backwards compatibility with tests using MockKeychainHelper\ntypealias MockKeychainHelper = MockKeychain\n\n/// Mock keychain that tracks secureClear calls for testing DH secret clearing\nfinal class TrackingMockKeychain: KeychainManagerProtocol {\n    private var storage: [String: Data] = [:]\n    private var serviceStorage: [String: [String: Data]] = [:]\n\n    /// Thread-safe counter for secureClear calls\n    private let lock = NSLock()\n    private var _secureClearDataCallCount = 0\n    private var _secureClearStringCallCount = 0\n\n    // BCH-01-009: Configurable error simulation for testing\n    var simulatedReadError: KeychainReadResult?\n    var simulatedSaveError: KeychainSaveResult?\n\n    var secureClearDataCallCount: Int {\n        lock.lock()\n        defer { lock.unlock() }\n        return _secureClearDataCallCount\n    }\n\n    var secureClearStringCallCount: Int {\n        lock.lock()\n        defer { lock.unlock() }\n        return _secureClearStringCallCount\n    }\n\n    var totalSecureClearCallCount: Int {\n        return secureClearDataCallCount + secureClearStringCallCount\n    }\n\n    func resetCounts() {\n        lock.lock()\n        defer { lock.unlock() }\n        _secureClearDataCallCount = 0\n        _secureClearStringCallCount = 0\n    }\n\n    func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {\n        storage[key] = keyData\n        return true\n    }\n\n    func getIdentityKey(forKey key: String) -> Data? {\n        storage[key]\n    }\n\n    func deleteIdentityKey(forKey key: String) -> Bool {\n        storage.removeValue(forKey: key)\n        return true\n    }\n\n    func deleteAllKeychainData() -> Bool {\n        storage.removeAll()\n        serviceStorage.removeAll()\n        return true\n    }\n\n    func secureClear(_ data: inout Data) {\n        lock.lock()\n        _secureClearDataCallCount += 1\n        lock.unlock()\n        data = Data()\n    }\n\n    func secureClear(_ string: inout String) {\n        lock.lock()\n        _secureClearStringCallCount += 1\n        lock.unlock()\n        string = \"\"\n    }\n\n    func verifyIdentityKeyExists() -> Bool {\n        storage[\"identity_noiseStaticKey\"] != nil\n    }\n\n    // BCH-01-009: New methods with proper error classification\n    func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {\n        if let simulated = simulatedReadError {\n            return simulated\n        }\n        if let data = storage[key] {\n            return .success(data)\n        }\n        return .itemNotFound\n    }\n\n    func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {\n        if let simulated = simulatedSaveError {\n            return simulated\n        }\n        storage[key] = keyData\n        return .success\n    }\n\n    func save(key: String, data: Data, service: String, accessible: CFString?) {\n        if serviceStorage[service] == nil {\n            serviceStorage[service] = [:]\n        }\n        serviceStorage[service]?[key] = data\n    }\n\n    func load(key: String, service: String) -> Data? {\n        serviceStorage[service]?[key]\n    }\n\n    func delete(key: String, service: String) {\n        serviceStorage[service]?.removeValue(forKey: key)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Mocks/MockTransport.swift",
    "content": "//\n// MockTransport.swift\n// bitchatTests\n//\n// Mock Transport implementation for unit testing ChatViewModel.\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Foundation\nimport Combine\nimport CoreBluetooth\n@testable import bitchat\n\n/// Mock Transport implementation for testing ChatViewModel in isolation.\n/// Records all method calls and allows test code to verify interactions.\nfinal class MockTransport: Transport {\n\n    // MARK: - Protocol Properties\n\n    weak var delegate: BitchatDelegate?\n    weak var peerEventsDelegate: TransportPeerEventsDelegate?\n\n    var myPeerID: PeerID = PeerID(str: \"TESTPEER\")\n    var myNickname: String = \"TestUser\"\n\n    private let peerSnapshotSubject = CurrentValueSubject<[TransportPeerSnapshot], Never>([])\n    var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {\n        peerSnapshotSubject.eraseToAnyPublisher()\n    }\n\n    // MARK: - Recording Properties (for test assertions)\n\n    private(set) var sentMessages: [(content: String, mentions: [String], messageID: String?, timestamp: Date?)] = []\n    private(set) var sentPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String, messageID: String)] = []\n    private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []\n    private(set) var sentDeliveryAcks: [(messageID: String, peerID: PeerID)] = []\n    private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []\n    private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []\n    private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []\n    private(set) var cancelledTransfers: [String] = []\n    private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []\n    private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []\n    private(set) var startServicesCallCount = 0\n    private(set) var stopServicesCallCount = 0\n    private(set) var emergencyDisconnectCallCount = 0\n    private(set) var broadcastAnnounceCallCount = 0\n    private(set) var triggeredHandshakes: [PeerID] = []\n\n    // MARK: - Configurable Mock State\n\n    var connectedPeers: Set<PeerID> = []\n    var reachablePeers: Set<PeerID> = []\n    var peerNicknames: [PeerID: String] = [:]\n    var peerFingerprints: [PeerID: String] = [:]\n    var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]\n    private let mockKeychain = MockKeychain()\n\n    // MARK: - Transport Protocol Implementation\n\n    func currentPeerSnapshots() -> [TransportPeerSnapshot] {\n        peerSnapshotSubject.value\n    }\n\n    func setNickname(_ nickname: String) {\n        myNickname = nickname\n    }\n\n    func startServices() {\n        startServicesCallCount += 1\n    }\n\n    func stopServices() {\n        stopServicesCallCount += 1\n    }\n\n    func emergencyDisconnectAll() {\n        emergencyDisconnectCallCount += 1\n        connectedPeers.removeAll()\n        reachablePeers.removeAll()\n    }\n\n    func isPeerConnected(_ peerID: PeerID) -> Bool {\n        connectedPeers.contains(peerID)\n    }\n\n    func isPeerReachable(_ peerID: PeerID) -> Bool {\n        reachablePeers.contains(peerID) || connectedPeers.contains(peerID)\n    }\n\n    func peerNickname(peerID: PeerID) -> String? {\n        peerNicknames[peerID]\n    }\n\n    func getPeerNicknames() -> [PeerID: String] {\n        peerNicknames\n    }\n\n    func getFingerprint(for peerID: PeerID) -> String? {\n        peerFingerprints[peerID]\n    }\n\n    func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState {\n        peerNoiseStates[peerID] ?? .none\n    }\n\n    func triggerHandshake(with peerID: PeerID) {\n        triggeredHandshakes.append(peerID)\n    }\n\n    func getNoiseService() -> NoiseEncryptionService {\n        NoiseEncryptionService(keychain: mockKeychain)\n    }\n\n    // MARK: - Messaging\n\n    func sendMessage(_ content: String, mentions: [String]) {\n        sentMessages.append((content, mentions, nil, nil))\n    }\n\n    func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {\n        sentMessages.append((content, mentions, messageID, timestamp))\n    }\n\n    func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {\n        sentPrivateMessages.append((content, peerID, recipientNickname, messageID))\n    }\n\n    func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {\n        sentReadReceipts.append((receipt, peerID))\n    }\n\n    func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {\n        sentFavoriteNotifications.append((peerID, isFavorite))\n    }\n\n    func sendBroadcastAnnounce() {\n        broadcastAnnounceCallCount += 1\n    }\n\n    func sendDeliveryAck(for messageID: String, to peerID: PeerID) {\n        sentDeliveryAcks.append((messageID, peerID))\n    }\n\n    func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {\n        sentBroadcastFiles.append((packet, transferId))\n    }\n\n    func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {\n        sentPrivateFiles.append((packet, peerID, transferId))\n    }\n\n    func cancelTransfer(_ transferId: String) {\n        cancelledTransfers.append(transferId)\n    }\n\n    func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {\n        sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))\n    }\n\n    func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {\n        sentVerifyResponses.append((peerID, noiseKeyHex, nonceA))\n    }\n\n    // MARK: - Test Helpers\n\n    /// Clears all recorded method calls for fresh assertions\n    func resetRecordings() {\n        sentMessages.removeAll()\n        sentPrivateMessages.removeAll()\n        sentReadReceipts.removeAll()\n        sentDeliveryAcks.removeAll()\n        sentFavoriteNotifications.removeAll()\n        sentBroadcastFiles.removeAll()\n        sentPrivateFiles.removeAll()\n        cancelledTransfers.removeAll()\n        sentVerifyChallenges.removeAll()\n        sentVerifyResponses.removeAll()\n        startServicesCallCount = 0\n        stopServicesCallCount = 0\n        emergencyDisconnectCallCount = 0\n        broadcastAnnounceCallCount = 0\n        triggeredHandshakes.removeAll()\n    }\n\n    /// Simulates a peer connecting\n    func simulateConnect(_ peerID: PeerID, nickname: String? = nil) {\n        connectedPeers.insert(peerID)\n        if let nickname = nickname {\n            peerNicknames[peerID] = nickname\n        }\n        delegate?.didConnectToPeer(peerID)\n        delegate?.didUpdatePeerList(Array(connectedPeers))\n        publishPeerSnapshots()\n    }\n\n    /// Simulates a peer disconnecting\n    func simulateDisconnect(_ peerID: PeerID) {\n        connectedPeers.remove(peerID)\n        peerNicknames.removeValue(forKey: peerID)\n        delegate?.didDisconnectFromPeer(peerID)\n        delegate?.didUpdatePeerList(Array(connectedPeers))\n        publishPeerSnapshots()\n    }\n\n    /// Simulates receiving a message\n    func simulateIncomingMessage(_ message: BitchatMessage) {\n        delegate?.didReceiveMessage(message)\n    }\n\n    /// Simulates receiving a public message\n    func simulateIncomingPublicMessage(\n        from peerID: PeerID,\n        nickname: String,\n        content: String,\n        timestamp: Date = Date(),\n        messageID: String? = nil\n    ) {\n        delegate?.didReceivePublicMessage(\n            from: peerID,\n            nickname: nickname,\n            content: content,\n            timestamp: timestamp,\n            messageID: messageID\n        )\n    }\n\n    /// Simulates Bluetooth state change\n    func simulateBluetoothStateChange(_ state: CBManagerState) {\n        delegate?.didUpdateBluetoothState(state)\n    }\n\n    /// Updates the peer snapshot publisher\n    func updatePeerSnapshots(_ snapshots: [TransportPeerSnapshot]) {\n        peerSnapshotSubject.send(snapshots)\n        Task { @MainActor [weak self] in\n            self?.peerEventsDelegate?.didUpdatePeerSnapshots(snapshots)\n        }\n    }\n\n    private func publishPeerSnapshots() {\n        let now = Date()\n        let snapshots = connectedPeers.map { peerID in\n            TransportPeerSnapshot(\n                peerID: peerID,\n                nickname: peerNicknames[peerID] ?? \"\",\n                isConnected: true,\n                noisePublicKey: Data(hexString: peerID.bare),\n                lastSeen: now\n            )\n        }\n        updatePeerSnapshots(snapshots)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Noise/NoiseCoverageTests.swift",
    "content": "import CryptoKit\nimport Foundation\nimport Testing\n\n@testable import bitchat\n\n@Suite(\"Noise Coverage Tests\")\nstruct NoiseCoverageTests {\n    private let keychain = MockKeychain()\n    private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()\n    private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()\n    private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()\n\n    private let alicePeerID = PeerID(str: \"0011223344556677\")\n    private let bobPeerID = PeerID(str: \"8899aabbccddeeff\")\n    private let charliePeerID = PeerID(str: \"fedcba9876543210\")\n\n    @Test(\"Protocol metadata and handshake patterns expose expected values\")\n    func protocolMetadataAndHandshakePatterns() {\n        let ikName = NoiseProtocolName(pattern: NoisePattern.IK.patternName)\n        #expect(ikName.pattern == \"IK\")\n        #expect(ikName.dh == \"25519\")\n        #expect(ikName.cipher == \"ChaChaPoly\")\n        #expect(ikName.hash == \"SHA256\")\n        #expect(ikName.fullName == \"Noise_IK_25519_ChaChaPoly_SHA256\")\n\n        #expect(NoisePattern.XX.patternName == \"XX\")\n        #expect(NoisePattern.IK.patternName == \"IK\")\n        #expect(NoisePattern.NK.patternName == \"NK\")\n\n        let ikPatterns = NoisePattern.IK.messagePatterns\n        #expect(ikPatterns.count == 2)\n        #expect(ikPatterns[0] == [.e, .es, .s, .ss])\n        #expect(ikPatterns[1] == [.e, .ee, .se])\n\n        let nkPatterns = NoisePattern.NK.messagePatterns\n        #expect(nkPatterns.count == 2)\n        #expect(nkPatterns[0] == [.e, .es])\n        #expect(nkPatterns[1] == [.e, .ee])\n    }\n\n    @Test(\"Symmetric state supports long protocol names and mixKeyAndHash\")\n    func symmetricStateLongNameAndMixKeyAndHash() {\n        let longName = String(repeating: \"NoiseProtocol_\", count: 3)\n        let symmetricState = NoiseSymmetricState(protocolName: longName)\n        let initialHash = symmetricState.getHandshakeHash()\n\n        #expect(initialHash.count == 32)\n        #expect(!symmetricState.hasCipherKey())\n\n        symmetricState.mixKeyAndHash(Data(\"input-key-material\".utf8))\n\n        #expect(symmetricState.hasCipherKey())\n        #expect(symmetricState.getHandshakeHash() != initialHash)\n    }\n\n    @Test(\"Cipher state rejects duplicate and stale extracted nonces\")\n    func cipherStateRejectsDuplicateAndStaleNonces() throws {\n        let key = SymmetricKey(size: .bits256)\n        let receiver = NoiseCipherState(key: key, useExtractedNonce: true)\n        let initialPayload = try makeExtractedNoncePayload(\n            key: key,\n            nonce: 0,\n            plaintext: Data(\"nonce-0\".utf8)\n        )\n\n        let initialPlaintext = try receiver.decrypt(ciphertext: initialPayload)\n        #expect(initialPlaintext == Data(\"nonce-0\".utf8))\n\n        #expect(throws: (any Error).self) {\n            try receiver.decrypt(ciphertext: initialPayload)\n        }\n\n        for nonce in 1...1024 {\n            let payload = try makeExtractedNoncePayload(\n                key: key,\n                nonce: UInt64(nonce),\n                plaintext: Data(\"nonce-\\(nonce)\".utf8)\n            )\n            let plaintext = try receiver.decrypt(ciphertext: payload)\n            #expect(plaintext == Data(\"nonce-\\(nonce)\".utf8))\n        }\n\n        #expect(throws: (any Error).self) {\n            try receiver.decrypt(ciphertext: initialPayload)\n        }\n    }\n\n    @Test(\"Cipher state handles large nonce jumps and associated-data mismatches\")\n    func cipherStateHandlesLargeJumpsAndAADMismatch() throws {\n        let key = SymmetricKey(size: .bits256)\n        let extractedReceiver = NoiseCipherState(key: key, useExtractedNonce: true)\n\n        let jumped = try makeExtractedNoncePayload(\n            key: key,\n            nonce: 1500,\n            plaintext: Data(\"future\".utf8)\n        )\n        let slightlyOlder = try makeExtractedNoncePayload(\n            key: key,\n            nonce: 1499,\n            plaintext: Data(\"older\".utf8)\n        )\n        let tooOld = try makeExtractedNoncePayload(\n            key: key,\n            nonce: 100,\n            plaintext: Data(\"ancient\".utf8)\n        )\n\n        #expect(try extractedReceiver.decrypt(ciphertext: jumped) == Data(\"future\".utf8))\n        #expect(try extractedReceiver.decrypt(ciphertext: slightlyOlder) == Data(\"older\".utf8))\n        #expect(throws: (any Error).self) {\n            try extractedReceiver.decrypt(ciphertext: tooOld)\n        }\n\n        let sender = NoiseCipherState(key: key)\n        let receiver = NoiseCipherState(key: key)\n        let plaintext = Data(\"associated-data\".utf8)\n        let aad = Data(\"good-aad\".utf8)\n        let ciphertext = try sender.encrypt(plaintext: plaintext, associatedData: aad)\n\n        #expect(throws: (any Error).self) {\n            try receiver.decrypt(ciphertext: ciphertext, associatedData: Data(\"bad-aad\".utf8))\n        }\n        #expect(try receiver.decrypt(ciphertext: ciphertext, associatedData: aad) == plaintext)\n        #expect(throws: (any Error).self) {\n            try receiver.decrypt(ciphertext: Data(repeating: 0xAA, count: 15))\n        }\n    }\n\n    @Test(\"Cipher state covers nonce guard rails and extracted payload bounds\")\n    func cipherStateCoversNonceGuardRailsAndExtractedPayloadBounds() throws {\n        let uninitializedCipher = NoiseCipherState()\n        #expect(throws: NoiseError.uninitializedCipher) {\n            try uninitializedCipher.encrypt(plaintext: Data(\"missing-key\".utf8))\n        }\n        #expect(throws: NoiseError.uninitializedCipher) {\n            try uninitializedCipher.decrypt(ciphertext: Data(repeating: 0x00, count: 16))\n        }\n        #expect(try uninitializedCipher.extractNonceFromCiphertextPayloadForTesting(Data([0x00, 0x01, 0x02])) == nil)\n\n        let key = SymmetricKey(size: .bits256)\n\n        let highNonceCipher = NoiseCipherState(key: key)\n        highNonceCipher.setNonceForTesting(1_000_000_001)\n        #expect(throws: Never.self) {\n            _ = try highNonceCipher.encrypt(plaintext: Data(\"high-nonce\".utf8))\n        }\n\n        let exhaustedCipher = NoiseCipherState(key: key)\n        exhaustedCipher.setNonceForTesting(UInt64(UInt32.max))\n        #expect(throws: NoiseError.nonceExceeded) {\n            try exhaustedCipher.encrypt(plaintext: Data(\"nonce-limit\".utf8))\n        }\n    }\n\n    @Test(\"Handshake validation rejects malformed keys and messages\")\n    func handshakeValidationRejectsMalformedInputs() throws {\n        let responder = NoiseHandshakeState(\n            role: .responder,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n\n        #expect(throws: (any Error).self) {\n            try responder.readMessage(Data(repeating: 0x00, count: 31))\n        }\n\n        let invalidKeys = [\n            Data(),\n            Data(repeating: 0x00, count: 32),\n            Data([0x01] + Array(repeating: 0x00, count: 31)),\n            Data(repeating: 0xFF, count: 32),\n        ]\n\n        for invalidKey in invalidKeys {\n            #expect(throws: (any Error).self) {\n                _ = try NoiseHandshakeState.validatePublicKey(invalidKey)\n            }\n        }\n\n        let valid = aliceStaticKey.publicKey.rawRepresentation\n        let roundTripped = try NoiseHandshakeState.validatePublicKey(valid)\n        #expect(roundTripped.rawRepresentation == valid)\n\n        let initiator = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: aliceStaticKey\n        )\n        let responderForTamper = NoiseHandshakeState(\n            role: .responder,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n\n        let message1 = try initiator.writeMessage()\n        _ = try responderForTamper.readMessage(message1)\n        var message2 = try responderForTamper.writeMessage()\n        message2[40] ^= 0x01\n\n        #expect(throws: (any Error).self) {\n            try initiator.readMessage(message2)\n        }\n    }\n\n    @Test(\"Handshake readers reject invalid ephemeral and truncated static payloads\")\n    func handshakeReadersRejectInvalidEphemeralAndTruncatedStaticPayloads() throws {\n        let invalidEphemeralResponder = NoiseHandshakeState(\n            role: .responder,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n\n        #expect(throws: NoiseError.invalidMessage) {\n            try invalidEphemeralResponder.readMessage(Data(repeating: 0x00, count: 32))\n        }\n\n        let truncatedStaticInitiator = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: aliceStaticKey\n        )\n        _ = try truncatedStaticInitiator.writeMessage()\n        let responderEphemeralOnly = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation\n\n        #expect(throws: NoiseError.invalidMessage) {\n            try truncatedStaticInitiator.readMessage(responderEphemeralOnly)\n        }\n    }\n\n    @Test(\"IK handshake completes and supports transport messages\")\n    func ikHandshakeCompletesAndSupportsTransportMessages() throws {\n        let initiator = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .IK,\n            keychain: keychain,\n            localStaticKey: aliceStaticKey,\n            remoteStaticKey: bobStaticKey.publicKey\n        )\n        let responder = NoiseHandshakeState(\n            role: .responder,\n            pattern: .IK,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n\n        let outboundPayload = Data(\"ik-outbound\".utf8)\n        let returnPayload = Data(\"ik-return\".utf8)\n        let message1 = try initiator.writeMessage(payload: outboundPayload)\n\n        #expect(try responder.readMessage(message1) == outboundPayload)\n\n        let message2 = try responder.writeMessage(payload: returnPayload)\n        #expect(try initiator.readMessage(message2) == returnPayload)\n\n        #expect(initiator.isHandshakeComplete())\n        #expect(responder.isHandshakeComplete())\n\n        let (initiatorSend, initiatorReceive, initiatorHash) = try initiator.getTransportCiphers(\n            useExtractedNonce: true\n        )\n        let (responderSend, responderReceive, responderHash) = try responder.getTransportCiphers(\n            useExtractedNonce: true\n        )\n\n        #expect(initiatorHash == responderHash)\n\n        let clientCiphertext = try initiatorSend.encrypt(plaintext: Data(\"ik-transport\".utf8))\n        #expect(try responderReceive.decrypt(ciphertext: clientCiphertext) == Data(\"ik-transport\".utf8))\n\n        let serverCiphertext = try responderSend.encrypt(plaintext: Data(\"ik-response\".utf8))\n        #expect(try initiatorReceive.decrypt(ciphertext: serverCiphertext) == Data(\"ik-response\".utf8))\n    }\n\n    @Test(\"NK handshake requires a responder static key and supports transport messages\")\n    func nkHandshakeRequiresStaticAndSupportsTransportMessages() throws {\n        let missingStaticInitiator = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .NK,\n            keychain: keychain,\n            localStaticKey: aliceStaticKey\n        )\n\n        #expect(throws: (any Error).self) {\n            try missingStaticInitiator.writeMessage()\n        }\n\n        let initiator = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .NK,\n            keychain: keychain,\n            localStaticKey: aliceStaticKey,\n            remoteStaticKey: bobStaticKey.publicKey\n        )\n        let responder = NoiseHandshakeState(\n            role: .responder,\n            pattern: .NK,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n\n        let outboundPayload = Data(\"nk-outbound\".utf8)\n        let returnPayload = Data(\"nk-return\".utf8)\n        let message1 = try initiator.writeMessage(payload: outboundPayload)\n        #expect(try responder.readMessage(message1) == outboundPayload)\n\n        let message2 = try responder.writeMessage(payload: returnPayload)\n        #expect(try initiator.readMessage(message2) == returnPayload)\n\n        #expect(initiator.isHandshakeComplete())\n        #expect(responder.isHandshakeComplete())\n\n        let (initiatorSend, initiatorReceive, initiatorHash) = try initiator.getTransportCiphers(\n            useExtractedNonce: true\n        )\n        let (responderSend, responderReceive, responderHash) = try responder.getTransportCiphers(\n            useExtractedNonce: true\n        )\n\n        #expect(initiatorHash == responderHash)\n\n        let clientCiphertext = try initiatorSend.encrypt(plaintext: Data(\"nk-transport\".utf8))\n        #expect(try responderReceive.decrypt(ciphertext: clientCiphertext) == Data(\"nk-transport\".utf8))\n\n        let serverCiphertext = try responderSend.encrypt(plaintext: Data(\"nk-response\".utf8))\n        #expect(try initiatorReceive.decrypt(ciphertext: serverCiphertext) == Data(\"nk-response\".utf8))\n    }\n\n    @Test(\"Responder-side NK writes require peer ephemeral input\")\n    func responderWritesRequirePeerEphemeralInput() {\n        let nkResponder = NoiseHandshakeState(\n            role: .responder,\n            pattern: .NK,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n\n        #expect(throws: NoiseError.missingKeys) {\n            try nkResponder.writeMessage()\n        }\n    }\n\n    @Test(\"Direct DH helpers reject missing keys across all patterns\")\n    func directDHHelpersRejectMissingKeysAcrossAllPatterns() throws {\n        let eeState = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: aliceStaticKey\n        )\n        #expect(throws: NoiseError.missingKeys) {\n            try eeState.performDHOperationForTesting(.ee)\n        }\n\n        let esInitiator = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: aliceStaticKey\n        )\n        #expect(throws: NoiseError.missingKeys) {\n            try esInitiator.performDHOperationForTesting(.es)\n        }\n\n        let esResponder = NoiseHandshakeState(\n            role: .responder,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: nil\n        )\n        #expect(throws: NoiseError.missingKeys) {\n            try esResponder.performDHOperationForTesting(.es)\n        }\n\n        let seInitiator = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: nil\n        )\n        #expect(throws: NoiseError.missingKeys) {\n            try seInitiator.performDHOperationForTesting(.se)\n        }\n\n        let seResponder = NoiseHandshakeState(\n            role: .responder,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n        #expect(throws: NoiseError.missingKeys) {\n            try seResponder.performDHOperationForTesting(.se)\n        }\n\n        let ssState = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: nil\n        )\n        #expect(throws: NoiseError.missingKeys) {\n            try ssState.performDHOperationForTesting(.ss)\n        }\n\n        #expect(throws: Never.self) {\n            try eeState.performDHOperationForTesting(.e)\n            try eeState.performDHOperationForTesting(.s)\n        }\n    }\n\n    @Test(\"Prepared handshake writers cover remaining missing-key branches\")\n    func preparedHandshakeWritersCoverRemainingMissingKeyBranches() {\n        let eeResponder = NoiseHandshakeState(\n            role: .responder,\n            pattern: .NK,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n        eeResponder.setCurrentPatternForTesting(1)\n        #expect(throws: NoiseError.missingKeys) {\n            try eeResponder.writeMessage()\n        }\n\n        let seInitiator = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: aliceStaticKey\n        )\n        seInitiator.setCurrentPatternForTesting(2)\n        #expect(throws: NoiseError.missingKeys) {\n            try seInitiator.writeMessage()\n        }\n\n        let seResponder = NoiseHandshakeState(\n            role: .responder,\n            pattern: .IK,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n        seResponder.setCurrentPatternForTesting(1)\n        seResponder.setRemoteEphemeralPublicKeyForTesting(Curve25519.KeyAgreement.PrivateKey().publicKey)\n        #expect(throws: NoiseError.missingKeys) {\n            try seResponder.writeMessage()\n        }\n    }\n\n    @Test(\"Completed handshakes reject additional reads and writes\")\n    func completedHandshakesRejectAdditionalReadsAndWrites() throws {\n        let initiator = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .IK,\n            keychain: keychain,\n            localStaticKey: aliceStaticKey,\n            remoteStaticKey: bobStaticKey.publicKey\n        )\n        let responder = NoiseHandshakeState(\n            role: .responder,\n            pattern: .IK,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n\n        let message1 = try initiator.writeMessage(payload: Data(\"first\".utf8))\n        _ = try responder.readMessage(message1)\n        let message2 = try responder.writeMessage(payload: Data(\"second\".utf8))\n        _ = try initiator.readMessage(message2)\n\n        #expect(throws: NoiseError.handshakeComplete) {\n            try initiator.writeMessage()\n        }\n        #expect(throws: NoiseError.handshakeComplete) {\n            try responder.readMessage(message1)\n        }\n    }\n\n    @Test(\"XX final message requires a local static key\")\n    func xxFinalMessageRequiresLocalStaticKey() throws {\n        let initiator = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: nil\n        )\n        let responder = NoiseHandshakeState(\n            role: .responder,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n\n        let message1 = try initiator.writeMessage()\n        _ = try responder.readMessage(message1)\n        let message2 = try responder.writeMessage()\n        _ = try initiator.readMessage(message2)\n\n        #expect(throws: (any Error).self) {\n            try initiator.writeMessage()\n        }\n    }\n\n    @Test(\"Responder start handshake is empty and transport ciphers require completion\")\n    func responderStartHandshakeAndIncompleteTransportCiphers() throws {\n        let responderSession = NoiseSession(\n            peerID: bobPeerID,\n            role: .responder,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n        let incompleteHandshake = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: aliceStaticKey\n        )\n\n        #expect(try responderSession.startHandshake().isEmpty)\n        #expect(responderSession.getState() == .handshaking)\n\n        #expect(throws: (any Error).self) {\n            _ = try incompleteHandshake.getTransportCiphers(useExtractedNonce: true)\n        }\n    }\n\n    @Test(\"Session manager callbacks establish and failed handshakes clean up state\")\n    func sessionManagerCallbacksAndFailureCleanup() async throws {\n        let establishedRecorder = SessionCallbackRecorder()\n        let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)\n        let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)\n\n        aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)\n        bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)\n\n        try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)\n\n        let didEstablish = await TestHelpers.waitUntil(\n            { establishedRecorder.establishedCount == 2 },\n            timeout: 0.5\n        )\n        #expect(didEstablish)\n        #expect(establishedRecorder.establishedPeerIDs.contains(alicePeerID))\n        #expect(establishedRecorder.establishedPeerIDs.contains(bobPeerID))\n\n        let failureRecorder = SessionCallbackRecorder()\n        let failingManager = NoiseSessionManager(localStaticKey: charlieStaticKey, keychain: keychain)\n        failingManager.onSessionFailed = failureRecorder.recordFailure(peerID:error:)\n\n        #expect(throws: (any Error).self) {\n            try failingManager.handleIncomingHandshake(\n                from: charliePeerID,\n                message: Data(repeating: 0x00, count: 31)\n            )\n        }\n\n        let didFail = await TestHelpers.waitUntil(\n            { failureRecorder.failureCount == 1 },\n            timeout: 0.5\n        )\n        #expect(didFail)\n        #expect(failingManager.getSession(for: charliePeerID) == nil)\n    }\n\n    @Test(\"Session manager cleans up initiator sessions after start-handshake failures\")\n    func sessionManagerCleansUpInitiatorSessionsAfterStartHandshakeFailures() {\n        let manager = NoiseSessionManager(\n            localStaticKey: aliceStaticKey,\n            keychain: keychain,\n            sessionFactory: { peerID, role in\n                FailingNoiseSession(\n                    peerID: peerID,\n                    role: role,\n                    keychain: self.keychain,\n                    localStaticKey: self.aliceStaticKey\n                )\n            }\n        )\n\n        #expect(throws: FailingNoiseSession.Error.synthetic) {\n            try manager.initiateHandshake(with: alicePeerID)\n        }\n        #expect(manager.getSession(for: alicePeerID) == nil)\n    }\n\n    @Test(\"Session manager rekeys established sessions and replaces partial handshakes\")\n    func sessionManagerRekeysAndReplacesSessions() throws {\n        let manager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)\n\n        #expect(throws: NoiseSessionError.sessionNotFound) {\n            try manager.encrypt(Data(\"missing\".utf8), for: alicePeerID)\n        }\n        #expect(throws: NoiseSessionError.sessionNotFound) {\n            try manager.decrypt(Data(\"missing\".utf8), from: alicePeerID)\n        }\n\n        let initialHandshake = try manager.initiateHandshake(with: alicePeerID)\n        #expect(!initialHandshake.isEmpty)\n        let firstSession = try #require(manager.getSession(for: alicePeerID))\n\n        let restartedHandshake = try manager.initiateHandshake(with: alicePeerID)\n        let restartedSession = try #require(manager.getSession(for: alicePeerID))\n\n        #expect(!restartedHandshake.isEmpty)\n        #expect(restartedSession !== firstSession)\n\n        let restartedInitiator = NoiseSession(\n            peerID: alicePeerID,\n            role: .initiator,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n        let replacementMessage = try restartedInitiator.startHandshake()\n        let replacementResponse = try manager.handleIncomingHandshake(\n            from: alicePeerID,\n            message: replacementMessage\n        )\n        let replacementSession = try #require(manager.getSession(for: alicePeerID))\n\n        #expect(replacementResponse != nil)\n        #expect(replacementSession !== restartedSession)\n\n        let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)\n        let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)\n        try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)\n\n        let establishedSession = try #require(\n            aliceManager.getSession(for: alicePeerID) as? SecureNoiseSession\n        )\n        establishedSession.setMessageCountForTesting(\n            UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)\n        )\n\n        let sessionsNeedingRekey = aliceManager.getSessionsNeedingRekey()\n        #expect(sessionsNeedingRekey.contains { $0.peerID == alicePeerID && $0.needsRekey })\n\n        #expect(throws: NoiseSessionError.alreadyEstablished) {\n            try aliceManager.initiateHandshake(with: alicePeerID)\n        }\n\n        try aliceManager.initiateRekey(for: alicePeerID)\n        let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))\n\n        #expect(rekeyedSession !== establishedSession)\n        #expect(rekeyedSession.getState() == .handshaking)\n    }\n\n    @Test(\"Secure noise sessions enforce limits and renegotiation thresholds\")\n    func secureNoiseSessionsEnforceLimitsAndThresholds() throws {\n        let initiator = SecureNoiseSession(\n            peerID: alicePeerID,\n            role: .initiator,\n            keychain: keychain,\n            localStaticKey: aliceStaticKey\n        )\n        let responder = SecureNoiseSession(\n            peerID: bobPeerID,\n            role: .responder,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n\n        try establishSessions(initiator: initiator, responder: responder)\n\n        responder.setMessageCountForTesting(0)\n        responder.setLastActivityTimeForTesting(Date())\n        #expect(!responder.needsRenegotiation())\n\n        responder.setMessageCountForTesting(\n            UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)\n        )\n        #expect(responder.needsRenegotiation())\n\n        responder.setMessageCountForTesting(0)\n        responder.setLastActivityTimeForTesting(\n            Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1))\n        )\n        #expect(responder.needsRenegotiation())\n\n        initiator.setMessageCountForTesting(NoiseSecurityConstants.maxMessagesPerSession)\n        #expect(throws: (any Error).self) {\n            try initiator.encrypt(Data(\"exhausted\".utf8))\n        }\n\n        initiator.setMessageCountForTesting(0)\n        #expect(throws: (any Error).self) {\n            try initiator.encrypt(Data(repeating: 0xAB, count: NoiseSecurityConstants.maxMessageSize + 1))\n        }\n\n        responder.setLastActivityTimeForTesting(Date())\n        #expect(throws: (any Error).self) {\n            try responder.decrypt(\n                Data(repeating: 0xCD, count: NoiseSecurityConstants.maxMessageSize + 1)\n            )\n        }\n\n        let transportCiphertext = try initiator.encrypt(Data(\"secure-session\".utf8))\n        #expect(try responder.decrypt(transportCiphertext) == Data(\"secure-session\".utf8))\n    }\n\n    @Test(\"Secure noise sessions expire based on session start time\")\n    func secureNoiseSessionsExpireBasedOnSessionStartTime() throws {\n        let initiator = SecureNoiseSession(\n            peerID: alicePeerID,\n            role: .initiator,\n            keychain: keychain,\n            localStaticKey: aliceStaticKey\n        )\n        let responder = SecureNoiseSession(\n            peerID: bobPeerID,\n            role: .responder,\n            keychain: keychain,\n            localStaticKey: bobStaticKey\n        )\n\n        try establishSessions(initiator: initiator, responder: responder)\n\n        initiator.setSessionStartTimeForTesting(\n            Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1))\n        )\n        #expect(throws: (any Error).self) {\n            try initiator.encrypt(Data(\"expired\".utf8))\n        }\n\n        responder.setSessionStartTimeForTesting(\n            Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1))\n        )\n        #expect(throws: (any Error).self) {\n            try responder.decrypt(Data())\n        }\n    }\n\n    @Test(\"Rate limiter handles global message caps and per-peer resets\")\n    func rateLimiterGlobalMessageCapAndReset() async throws {\n        let globalLimiter = NoiseRateLimiter()\n        for index in 0..<NoiseSecurityConstants.maxGlobalMessagesPerSecond {\n            #expect(globalLimiter.allowMessage(from: PeerID(str: \"peer-\\(index)\")))\n        }\n        #expect(!globalLimiter.allowMessage(from: charliePeerID))\n\n        let peerLimiter = NoiseRateLimiter()\n        for _ in 0..<NoiseSecurityConstants.maxMessagesPerSecond {\n            #expect(peerLimiter.allowMessage(from: alicePeerID))\n        }\n        #expect(!peerLimiter.allowMessage(from: alicePeerID))\n\n        peerLimiter.reset(for: alicePeerID)\n        try await sleep(0.05)\n        #expect(peerLimiter.allowMessage(from: alicePeerID))\n    }\n\n    @Test(\"Cipher state decrypts high extracted nonces and rejects truncated extracted payloads\")\n    func cipherStateDecryptsHighExtractedNoncesAndRejectsTruncatedPayloads() throws {\n        let key = SymmetricKey(size: .bits256)\n        let receiver = NoiseCipherState(key: key, useExtractedNonce: true)\n        let highNoncePayload = try makeExtractedNoncePayload(\n            key: key,\n            nonce: 1_000_000_001,\n            plaintext: Data(\"high-nonce\".utf8)\n        )\n\n        #expect(try receiver.decrypt(ciphertext: highNoncePayload) == Data(\"high-nonce\".utf8))\n        #expect(throws: NoiseError.invalidCiphertext) {\n            try receiver.decrypt(ciphertext: extractedNoncePrefix(7))\n        }\n    }\n\n    private func establishSessions(initiator: NoiseSession, responder: NoiseSession) throws {\n        let message1 = try initiator.startHandshake()\n        let response2 = try responder.processHandshakeMessage(message1)\n        let message2 = try #require(response2)\n        let response3 = try initiator.processHandshakeMessage(message2)\n        let message3 = try #require(response3)\n        let final = try responder.processHandshakeMessage(message3)\n        #expect(final == nil)\n    }\n\n    private func establishManagerSessions(\n        aliceManager: NoiseSessionManager,\n        bobManager: NoiseSessionManager\n    ) throws {\n        let message1 = try aliceManager.initiateHandshake(with: alicePeerID)\n        let response2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1)\n        let message2 = try #require(response2)\n        let response3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message2)\n        let message3 = try #require(response3)\n        let final = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message3)\n        #expect(final == nil)\n    }\n\n    private func makeExtractedNoncePayload(\n        key: SymmetricKey,\n        nonce: UInt64,\n        plaintext: Data,\n        associatedData: Data = Data()\n    ) throws -> Data {\n        var fullNonce = Data(count: 12)\n        withUnsafeBytes(of: nonce.littleEndian) { bytes in\n            fullNonce.replaceSubrange(4..<12, with: bytes)\n        }\n\n        let sealedBox = try ChaChaPoly.seal(\n            plaintext,\n            using: key,\n            nonce: ChaChaPoly.Nonce(data: fullNonce),\n            authenticating: associatedData\n        )\n\n        return extractedNoncePrefix(nonce) + sealedBox.ciphertext + sealedBox.tag\n    }\n\n    private func extractedNoncePrefix(_ nonce: UInt64) -> Data {\n        withUnsafeBytes(of: nonce.bigEndian) { bytes in\n            Data(bytes.suffix(4))\n        }\n    }\n}\n\nprivate final class SessionCallbackRecorder: @unchecked Sendable {\n    private let lock = NSLock()\n    private var establishedEntries: [(PeerID, Data)] = []\n    private var failureEntries: [(PeerID, String)] = []\n\n    var establishedCount: Int {\n        lock.lock()\n        defer { lock.unlock() }\n        return establishedEntries.count\n    }\n\n    var failureCount: Int {\n        lock.lock()\n        defer { lock.unlock() }\n        return failureEntries.count\n    }\n\n    var establishedPeerIDs: [PeerID] {\n        lock.lock()\n        defer { lock.unlock() }\n        return establishedEntries.map(\\.0)\n    }\n\n    func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) {\n        lock.lock()\n        establishedEntries.append((peerID, remoteKey.rawRepresentation))\n        lock.unlock()\n    }\n\n    func recordFailure(peerID: PeerID, error: Error) {\n        lock.lock()\n        failureEntries.append((peerID, String(describing: error)))\n        lock.unlock()\n    }\n}\n\nprivate final class FailingNoiseSession: NoiseSession {\n    enum Error: Swift.Error {\n        case synthetic\n    }\n\n    override func startHandshake() throws -> Data {\n        throw Error.synthetic\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Noise/NoiseProtocolTests.swift",
    "content": "//\n// NoiseProtocolTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport CryptoKit\nimport Foundation\nimport Testing\n\n@testable import bitchat\n\n// MARK: - Test Vector Support\n\nstruct NoiseTestVector: Codable {\n    let protocol_name: String\n    let init_prologue: String\n    let init_static: String\n    let init_ephemeral: String\n    let init_psks: [String]?\n    let resp_prologue: String\n    let resp_static: String\n    let resp_ephemeral: String\n    let resp_psks: [String]?\n    let handshake_hash: String?\n    let messages: [TestMessage]\n    \n    struct TestMessage: Codable {\n        let payload: String\n        let ciphertext: String\n    }\n}\n\nextension Data {\n    init?(hex: String) {\n        let cleaned = hex.replacingOccurrences(of: \" \", with: \"\")\n        guard cleaned.count % 2 == 0 else { return nil }\n        var data = Data(capacity: cleaned.count / 2)\n        var index = cleaned.startIndex\n        while index < cleaned.endIndex {\n            let nextIndex = cleaned.index(index, offsetBy: 2)\n            guard let byte = UInt8(cleaned[index..<nextIndex], radix: 16) else { return nil }\n            data.append(byte)\n            index = nextIndex\n        }\n        self = data\n    }\n    \n    func hexString() -> String {\n        map { String(format: \"%02x\", $0) }.joined()\n    }\n}\n\nstruct NoiseProtocolTests {\n    \n    private let aliceKey = Curve25519.KeyAgreement.PrivateKey()\n    private let bobKey = Curve25519.KeyAgreement.PrivateKey()\n    private let mockKeychain = MockKeychain()\n    \n    private let alicePeerID = PeerID(str: UUID().uuidString)\n    private let bobPeerID = PeerID(str: UUID().uuidString)\n    \n    private let aliceSession: NoiseSession\n    private let bobSession: NoiseSession\n    \n    init() {\n        aliceSession = NoiseSession(\n            peerID: alicePeerID,\n            role: .initiator,\n            keychain: mockKeychain,\n            localStaticKey: aliceKey\n        )\n        \n        bobSession = NoiseSession(\n            peerID: bobPeerID,\n            role: .responder,\n            keychain: mockKeychain,\n            localStaticKey: bobKey\n        )\n    }\n    \n    // MARK: - Basic Handshake Tests\n    \n    @Test func xxPatternHandshake() throws {\n        // Alice starts handshake (message 1)\n        let message1 = try aliceSession.startHandshake()\n        #expect(!message1.isEmpty)\n        #expect(aliceSession.getState() == .handshaking)\n        \n        // Bob processes message 1 and creates message 2\n        let message2 = try bobSession.processHandshakeMessage(message1)\n        #expect(message2 != nil)\n        #expect(!message2!.isEmpty)\n        #expect(bobSession.getState() == .handshaking)\n        \n        // Alice processes message 2 and creates message 3\n        let message3 = try aliceSession.processHandshakeMessage(message2!)\n        #expect(message3 != nil)\n        #expect(!message3!.isEmpty)\n        #expect(aliceSession.getState() == .established)\n        \n        // Bob processes message 3 and completes handshake\n        let finalMessage = try bobSession.processHandshakeMessage(message3!)\n        #expect(finalMessage == nil)  // No more messages needed\n        #expect(bobSession.getState() == .established)\n        \n        // Verify both sessions are established\n        #expect(aliceSession.isEstablished())\n        #expect(bobSession.isEstablished())\n        \n        // Verify they have each other's static keys\n        #expect(\n            aliceSession.getRemoteStaticPublicKey()?.rawRepresentation\n            == bobKey.publicKey.rawRepresentation)\n        #expect(\n            bobSession.getRemoteStaticPublicKey()?.rawRepresentation\n            == aliceKey.publicKey.rawRepresentation)\n    }\n    \n    @Test func handshakeStateValidation() throws {\n        // Cannot process message before starting handshake\n        #expect(throws: NoiseSessionError.invalidState) {\n            try aliceSession.processHandshakeMessage(Data())\n        }\n        \n        // Start handshake\n        _ = try aliceSession.startHandshake()\n        \n        // Cannot start handshake twice\n        #expect(throws: NoiseSessionError.invalidState) {\n            try aliceSession.startHandshake()\n        }\n    }\n    \n    // MARK: - Encryption/Decryption Tests\n    \n    @Test func basicEncryptionDecryption() throws {\n        try performHandshake(initiator: aliceSession, responder: bobSession)\n        \n        let plaintext = \"Hello, Bob!\".data(using: .utf8)!\n        \n        // Alice encrypts\n        let ciphertext = try aliceSession.encrypt(plaintext)\n        #expect(ciphertext != plaintext)\n        #expect(ciphertext.count > plaintext.count)  // Should have overhead\n        \n        // Bob decrypts\n        let decrypted = try bobSession.decrypt(ciphertext)\n        #expect(decrypted == plaintext)\n    }\n    \n    @Test func bidirectionalEncryption() throws {\n        try performHandshake(initiator: aliceSession, responder: bobSession)\n        \n        // Alice -> Bob\n        let aliceMessage = \"Hello from Alice\".data(using: .utf8)!\n        let aliceCiphertext = try aliceSession.encrypt(aliceMessage)\n        let bobReceived = try bobSession.decrypt(aliceCiphertext)\n        #expect(bobReceived == aliceMessage)\n        \n        // Bob -> Alice\n        let bobMessage = \"Hello from Bob\".data(using: .utf8)!\n        let bobCiphertext = try bobSession.encrypt(bobMessage)\n        let aliceReceived = try aliceSession.decrypt(bobCiphertext)\n        #expect(aliceReceived == bobMessage)\n    }\n    \n    @Test func largeMessageEncryption() throws {\n        try performHandshake(initiator: aliceSession, responder: bobSession)\n        \n        // Create a large message\n        let largeMessage = TestHelpers.generateRandomData(length: 100_000)\n        \n        // Encrypt and decrypt\n        let ciphertext = try aliceSession.encrypt(largeMessage)\n        let decrypted = try bobSession.decrypt(ciphertext)\n        \n        #expect(decrypted == largeMessage)\n    }\n    \n    @Test func encryptionBeforeHandshake() {\n        let plaintext = \"test\".data(using: .utf8)!\n        \n        #expect(throws: NoiseSessionError.notEstablished) {\n            try aliceSession.encrypt(plaintext)\n        }\n        \n        #expect(throws: NoiseSessionError.notEstablished) {\n            try aliceSession.decrypt(plaintext)\n        }\n    }\n    \n    // MARK: - Session Manager Tests\n    \n    @Test func sessionManagerBasicOperations() throws {\n        let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)\n        \n        #expect(manager.getSession(for: alicePeerID) == nil)\n        \n        _ = try manager.initiateHandshake(with: alicePeerID)\n        #expect(manager.getSession(for: alicePeerID) != nil)\n        \n        // Get session\n        let retrieved = manager.getSession(for: alicePeerID)\n        #expect(retrieved != nil)\n        \n        // Remove session\n        manager.removeSession(for: alicePeerID)\n        #expect(manager.getSession(for: alicePeerID) == nil)\n    }\n    \n    @Test func sessionManagerHandshakeInitiation() throws {\n        let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)\n        \n        // Initiate handshake\n        let handshakeData = try manager.initiateHandshake(with: alicePeerID)\n        #expect(!handshakeData.isEmpty)\n        \n        // Session should exist\n        let session = manager.getSession(for: alicePeerID)\n        #expect(session != nil)\n        #expect(session?.getState() == .handshaking)\n    }\n    \n    @Test func sessionManagerIncomingHandshake() throws {\n        let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)\n        let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)\n        \n        // Alice initiates\n        let message1 = try aliceManager.initiateHandshake(with: alicePeerID)\n        \n        // Bob responds\n        let message2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1)\n        #expect(message2 != nil)\n        \n        // Continue handshake\n        let message3 = try aliceManager.handleIncomingHandshake(\n            from: alicePeerID, message: message2!)\n        #expect(message3 != nil)\n        \n        // Complete handshake\n        let finalMessage = try bobManager.handleIncomingHandshake(\n            from: bobPeerID, message: message3!)\n        #expect(finalMessage == nil)\n        \n        // Both should have established sessions\n        #expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true)\n        #expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)\n    }\n    \n    @Test func sessionManagerEncryptionDecryption() throws {\n        let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)\n        let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)\n        \n        // Establish sessions\n        try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)\n        \n        // Encrypt with manager\n        let plaintext = \"Test message\".data(using: .utf8)!\n        let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)\n        \n        // Decrypt with manager\n        let decrypted = try bobManager.decrypt(ciphertext, from: bobPeerID)\n        #expect(decrypted == plaintext)\n    }\n    \n    // MARK: - Security Tests\n    \n    @Test func tamperedCiphertextDetection() throws {\n        try performHandshake(initiator: aliceSession, responder: bobSession)\n        \n        let plaintext = \"Secret message\".data(using: .utf8)!\n        var ciphertext = try aliceSession.encrypt(plaintext)\n        \n        // Tamper with ciphertext\n        ciphertext[ciphertext.count / 2] ^= 0xFF\n        \n        // Decryption should fail\n        if #available(macOS 14.4, iOS 17.4, *) {\n            #expect(throws: CryptoKitError.authenticationFailure) {\n                try bobSession.decrypt(ciphertext)\n            }\n        } else {\n            #expect(throws: (any Error).self) {\n                try bobSession.decrypt(ciphertext)\n            }\n        }\n    }\n    \n    @Test func replayPrevention() throws {\n        try performHandshake(initiator: aliceSession, responder: bobSession)\n        \n        let plaintext = \"Test message\".data(using: .utf8)!\n        let ciphertext = try aliceSession.encrypt(plaintext)\n        \n        // First decryption should succeed\n        _ = try bobSession.decrypt(ciphertext)\n        \n        // Replaying the same ciphertext should fail\n        #expect(throws: NoiseError.replayDetected) {\n            try bobSession.decrypt(ciphertext)\n        }\n    }\n    \n    @Test func sessionIsolation() throws {\n        // Create two separate session pairs\n        let aliceSession1 = NoiseSession(\n            peerID: PeerID(str: \"peer1\"), role: .initiator, keychain: mockKeychain,\n            localStaticKey: aliceKey)\n        let bobSession1 = NoiseSession(\n            peerID: PeerID(str: \"alice1\"), role: .responder, keychain: mockKeychain,\n            localStaticKey: bobKey)\n        \n        let aliceSession2 = NoiseSession(\n            peerID: PeerID(str: \"peer2\"), role: .initiator, keychain: mockKeychain,\n            localStaticKey: aliceKey)\n        let bobSession2 = NoiseSession(\n            peerID: PeerID(str: \"alice2\"), role: .responder, keychain: mockKeychain,\n            localStaticKey: bobKey)\n        \n        // Establish both pairs\n        try performHandshake(initiator: aliceSession1, responder: bobSession1)\n        try performHandshake(initiator: aliceSession2, responder: bobSession2)\n        \n        // Encrypt with session 1\n        let plaintext = \"Secret\".data(using: .utf8)!\n        let ciphertext1 = try aliceSession1.encrypt(plaintext)\n        \n        // Should not be able to decrypt with session 2\n        if #available(macOS 14.4, iOS 17.4, *) {\n            #expect(throws: CryptoKitError.authenticationFailure) {\n                try bobSession2.decrypt(ciphertext1)\n            }\n        } else {\n            #expect(throws: (any Error).self) {\n                try bobSession2.decrypt(ciphertext1)\n            }\n        }\n        \n        // But should work with correct session\n        let decrypted = try bobSession1.decrypt(ciphertext1)\n        #expect(decrypted == plaintext)\n    }\n    \n    // MARK: - Session Recovery Tests\n    \n    @Test func peerRestartDetection() throws {\n        // Establish initial sessions\n        let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)\n        let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)\n        \n        try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)\n        \n        // Exchange some messages to establish nonce state\n        let message1 = try aliceManager.encrypt(\"Hello\".data(using: .utf8)!, for: alicePeerID)\n        _ = try bobManager.decrypt(message1, from: bobPeerID)\n        \n        let message2 = try bobManager.encrypt(\"World\".data(using: .utf8)!, for: bobPeerID)\n        _ = try aliceManager.decrypt(message2, from: alicePeerID)\n        \n        // Simulate Bob restart by creating new manager with same key\n        let bobManagerRestarted = NoiseSessionManager(\n            localStaticKey: bobKey, keychain: mockKeychain)\n        \n        // Bob initiates new handshake after restart\n        let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)\n        \n        // Alice should accept the new handshake (clearing old session)\n        let newHandshake2 = try aliceManager.handleIncomingHandshake(\n            from: alicePeerID, message: newHandshake1)\n        #expect(newHandshake2 != nil)\n        \n        // Complete the new handshake\n        let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(\n            from: bobPeerID, message: newHandshake2!)\n        #expect(newHandshake3 != nil)\n        _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)\n        \n        // Should be able to exchange messages with new sessions\n        let testMessage = \"After restart\".data(using: .utf8)!\n        let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)\n        let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)\n        #expect(decrypted == testMessage)\n    }\n    \n    @Test func nonceDesynchronizationRecovery() throws {\n        // Create two sessions\n        let aliceSession = NoiseSession(\n            peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)\n        let bobSession = NoiseSession(\n            peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)\n        \n        // Establish sessions\n        try performHandshake(initiator: aliceSession, responder: bobSession)\n        \n        // Exchange messages to advance nonces\n        for i in 0..<5 {\n            let msg = try aliceSession.encrypt(\"Message \\(i)\".data(using: .utf8)!)\n            _ = try bobSession.decrypt(msg)\n        }\n        \n        // Simulate desynchronization by encrypting but not decrypting\n        for i in 0..<3 {\n            _ = try aliceSession.encrypt(\"Lost message \\(i)\".data(using: .utf8)!)\n        }\n        \n        // With per-packet nonce carried, decryption should not throw here\n        let desyncMessage = try aliceSession.encrypt(\"This now succeeds\".data(using: .utf8)!)\n        #expect(throws: Never.self) {\n            try bobSession.decrypt(desyncMessage)\n        }\n    }\n    \n    @Test func concurrentEncryption() async throws {\n        // Test thread safety of encryption operations\n        let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)\n        let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)\n        \n        try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)\n        \n        let messageCount = 100\n        \n        try await confirmation(\"All messages encrypted and decrypted\", expectedCount: messageCount)\n        { completion in\n            var encryptedMessages: [Int: Data] = [:]\n            // Encrypt messages sequentially to avoid nonce races in manager\n            for i in 0..<messageCount {\n                let plaintext = \"Concurrent message \\(i)\".data(using: .utf8)!\n                let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)\n                encryptedMessages[i] = encrypted\n            }\n            \n            // Decrypt messages sequentially to avoid triggering anti-replay with reordering\n            for i in 0..<messageCount {\n                do {\n                    guard let encrypted = encryptedMessages[i] else {\n                        Issue.record(\"Missing encrypted message \\(i)\")\n                        return\n                    }\n                    let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)\n                    let expected = \"Concurrent message \\(i)\".data(using: .utf8)!\n                    #expect(decrypted == expected)\n                    completion()\n                } catch {\n                    Issue.record(\"Decryption failed for message \\(i): \\(error)\")\n                }\n            }\n        }\n    }\n    \n    @Test func sessionStaleDetection() throws {\n        // Test that sessions are properly marked as stale\n        let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)\n        let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)\n        \n        try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)\n        \n        // Get the session and check it needs renegotiation based on age\n        let sessions = aliceManager.getSessionsNeedingRekey()\n        \n        // New session should not need rekey\n        #expect(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey })\n    }\n    \n    @Test func handshakeAfterDecryptionFailure() throws {\n        // Test that handshake is properly initiated after decryption failure\n        let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)\n        let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)\n        \n        // Establish sessions\n        try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)\n        \n        // Create a corrupted message\n        var encrypted = try aliceManager.encrypt(\"Test\".data(using: .utf8)!, for: alicePeerID)\n        encrypted[10] ^= 0xFF  // Corrupt the data\n        \n        // Decryption should fail\n        if #available(macOS 14.4, iOS 17.4, *) {\n            #expect(throws: CryptoKitError.authenticationFailure) {\n                try bobManager.decrypt(encrypted, from: bobPeerID)\n            }\n        } else {\n            #expect(throws: (any Error).self) {\n                try bobManager.decrypt(encrypted, from: bobPeerID)\n            }\n        }\n        \n        // Bob should still have the session (it's not removed on single failure)\n        #expect(bobManager.getSession(for: bobPeerID) != nil)\n    }\n    \n    @Test func handshakeAlwaysAcceptedWithExistingSession() throws {\n        // Test that handshake is always accepted even with existing valid session\n        let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)\n        let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)\n        \n        // Establish sessions\n        try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)\n        \n        // Verify sessions are established\n        #expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true)\n        #expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)\n        \n        // Exchange messages to verify sessions work\n        let testMessage = \"Session works\".data(using: .utf8)!\n        let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)\n        let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)\n        #expect(decrypted == testMessage)\n        \n        // Alice clears her session (simulating decryption failure)\n        aliceManager.removeSession(for: alicePeerID)\n        \n        // Alice initiates new handshake despite Bob having valid session\n        let newHandshake1 = try aliceManager.initiateHandshake(with: alicePeerID)\n        \n        // Bob should accept the new handshake even though he has a valid session\n        let newHandshake2 = try bobManager.handleIncomingHandshake(\n            from: bobPeerID, message: newHandshake1)\n        #expect(newHandshake2 != nil, \"Bob should accept handshake despite having valid session\")\n        \n        // Complete the handshake\n        let newHandshake3 = try aliceManager.handleIncomingHandshake(\n            from: alicePeerID, message: newHandshake2!)\n        #expect(newHandshake3 != nil)\n        _ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)\n        \n        // Verify new sessions work\n        let testMessage2 = \"New session works\".data(using: .utf8)!\n        let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)\n        let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)\n        #expect(decrypted2 == testMessage2)\n    }\n    \n    @Test func nonceDesynchronizationCausesRehandshake() throws {\n        // Test that nonce desynchronization leads to proper re-handshake\n        let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)\n        let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)\n        \n        // Establish sessions\n        try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)\n        \n        // Exchange messages normally\n        for i in 0..<5 {\n            let msg = try aliceManager.encrypt(\"Message \\(i)\".data(using: .utf8)!, for: alicePeerID)\n            _ = try bobManager.decrypt(msg, from: bobPeerID)\n        }\n        \n        // Simulate desynchronization - Alice sends messages that Bob doesn't receive\n        for i in 0..<3 {\n            _ = try aliceManager.encrypt(\"Lost message \\(i)\".data(using: .utf8)!, for: alicePeerID)\n        }\n        \n        // With nonce carried in packet, decryption should not throw here\n        let desyncMessage = try aliceManager.encrypt(\n            \"This now succeeds\".data(using: .utf8)!, for: alicePeerID)\n        #expect(throws: Never.self) {\n            try bobManager.decrypt(desyncMessage, from: bobPeerID)\n        }\n        \n        // Bob clears session and initiates new handshake\n        bobManager.removeSession(for: bobPeerID)\n        let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)\n        \n        // Alice should accept despite having a \"valid\" (but desynced) session\n        let rehandshake2 = try aliceManager.handleIncomingHandshake(\n            from: alicePeerID, message: rehandshake1)\n        #expect(rehandshake2 != nil, \"Alice should accept handshake to fix desync\")\n        \n        // Complete handshake\n        let rehandshake3 = try bobManager.handleIncomingHandshake(\n            from: bobPeerID, message: rehandshake2!)\n        #expect(rehandshake3 != nil)\n        _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)\n        \n        // Verify communication works again\n        let testResynced = \"Resynced\".data(using: .utf8)!\n        let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)\n        let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)\n        #expect(decryptedResync == testResynced)\n    }\n    \n    // MARK: - Test Vector Tests\n    \n    @Test func noiseTestVectors() throws {\n        // Load test vectors from bundle\n        let testVectors = try loadTestVectors()\n        \n        for (index, testVector) in testVectors.enumerated() {\n            print(\"Running test vector \\(index + 1): \\(testVector.protocol_name)\")\n            try runTestVector(testVector)\n        }\n    }\n    \n    // MARK: - Helper Methods\n    \n    private func performHandshake(initiator: NoiseSession, responder: NoiseSession) throws {\n        let msg1 = try initiator.startHandshake()\n        let msg2 = try responder.processHandshakeMessage(msg1)!\n        let msg3 = try initiator.processHandshakeMessage(msg2)!\n        _ = try responder.processHandshakeMessage(msg3)\n    }\n    \n    private func establishManagerSessions(\n        aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager\n    ) throws {\n        let msg1 = try aliceManager.initiateHandshake(with: alicePeerID)\n        let msg2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1)!\n        let msg3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: msg2)!\n        _ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg3)\n    }\n    \n    private func loadTestVectors() throws -> [NoiseTestVector] {\n        // Try to load from test bundle\n        let testBundle = Bundle(for: MockKeychain.self)\n        guard let url = testBundle.url(forResource: \"NoiseTestVectors\", withExtension: \"json\")\n        else {\n            throw NSError(\n                domain: \"NoiseTests\", code: 1,\n                userInfo: [\n                    NSLocalizedDescriptionKey: \"Could not find NoiseTestVectors.json in test bundle\"\n                ])\n        }\n        \n        let data = try Data(contentsOf: url)\n        return try JSONDecoder().decode([NoiseTestVector].self, from: data)\n    }\n    \n    private func runTestVector(_ testVector: NoiseTestVector) throws {\n        // Parse test inputs\n        guard let initStatic = Data(hex: testVector.init_static),\n              let initEphemeral = Data(hex: testVector.init_ephemeral),\n              let respStatic = Data(hex: testVector.resp_static),\n              let respEphemeral = Data(hex: testVector.resp_ephemeral),\n              let prologue = Data(hex: testVector.init_prologue)\n        else {\n            throw NSError(\n                domain: \"NoiseTests\", code: 2,\n                userInfo: [NSLocalizedDescriptionKey: \"Failed to parse test vector hex strings\"])\n        }\n        \n        let expectedHash = testVector.handshake_hash.flatMap { Data(hex: $0) }\n        \n        // Create keys\n        guard\n            let initStaticKey = try? Curve25519.KeyAgreement.PrivateKey(\n                rawRepresentation: initStatic),\n            let initEphemeralKey = try? Curve25519.KeyAgreement.PrivateKey(\n                rawRepresentation: initEphemeral),\n            let respStaticKey = try? Curve25519.KeyAgreement.PrivateKey(\n                rawRepresentation: respStatic),\n            let respEphemeralKey = try? Curve25519.KeyAgreement.PrivateKey(\n                rawRepresentation: respEphemeral)\n        else {\n            throw NSError(\n                domain: \"NoiseTests\", code: 3,\n                userInfo: [NSLocalizedDescriptionKey: \"Failed to create keys from test vectors\"])\n        }\n        \n        let keychain = MockKeychain()\n        \n        // Create handshake states\n        let initiatorHandshake = NoiseHandshakeState(\n            role: .initiator,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: initStaticKey,\n            prologue: prologue,\n            predeterminedEphemeralKey: initEphemeralKey\n        )\n        \n        let responderHandshake = NoiseHandshakeState(\n            role: .responder,\n            pattern: .XX,\n            keychain: keychain,\n            localStaticKey: respStaticKey,\n            prologue: prologue,\n            predeterminedEphemeralKey: respEphemeralKey\n        )\n        \n        // For XX pattern, we have 3 handshake messages, then transport messages\n        // The test vector messages are ordered as: [msg1, msg2, msg3, transport1, transport2, ...]\n        \n        guard testVector.messages.count >= 3 else {\n            throw NSError(\n                domain: \"NoiseTests\", code: 5,\n                userInfo: [NSLocalizedDescriptionKey: \"Test vector must have at least 3 messages for XX pattern\"])\n        }\n        \n        // Message 1: Initiator -> Responder (e)\n        guard let payload1 = Data(hex: testVector.messages[0].payload),\n              let expectedCiphertext1 = Data(hex: testVector.messages[0].ciphertext) else {\n            throw NSError(\n                domain: \"NoiseTests\", code: 4,\n                userInfo: [NSLocalizedDescriptionKey: \"Message 1: Failed to parse hex\"])\n        }\n        \n        let msg1 = try initiatorHandshake.writeMessage(payload: payload1)\n        #expect(!msg1.isEmpty, \"Message 1 should not be empty\")\n        #expect(msg1 == expectedCiphertext1, \"Message 1 ciphertext should match expected value. Got: \\(msg1.hexString()), Expected: \\(expectedCiphertext1.hexString())\")\n        \n        let decrypted1 = try responderHandshake.readMessage(msg1)\n        #expect(decrypted1 == payload1, \"Message 1: Decrypted payload should match original\")\n        \n        // Message 2: Responder -> Initiator (e, ee, s, es)\n        guard let payload2 = Data(hex: testVector.messages[1].payload),\n              let expectedCiphertext2 = Data(hex: testVector.messages[1].ciphertext) else {\n            throw NSError(\n                domain: \"NoiseTests\", code: 4,\n                userInfo: [NSLocalizedDescriptionKey: \"Message 2: Failed to parse hex\"])\n        }\n        \n        let msg2 = try responderHandshake.writeMessage(payload: payload2)\n        #expect(!msg2.isEmpty, \"Message 2 should not be empty\")\n        #expect(msg2 == expectedCiphertext2, \"Message 2 ciphertext should match expected value. Got: \\(msg2.hexString()), Expected: \\(expectedCiphertext2.hexString())\")\n        \n        let decrypted2 = try initiatorHandshake.readMessage(msg2)\n        #expect(decrypted2 == payload2, \"Message 2: Decrypted payload should match original\")\n        \n        // Message 3: Initiator -> Responder (s, se)\n        guard let payload3 = Data(hex: testVector.messages[2].payload),\n              let expectedCiphertext3 = Data(hex: testVector.messages[2].ciphertext) else {\n            throw NSError(\n                domain: \"NoiseTests\", code: 4,\n                userInfo: [NSLocalizedDescriptionKey: \"Message 3: Failed to parse hex\"])\n        }\n        \n        let msg3 = try initiatorHandshake.writeMessage(payload: payload3)\n        #expect(!msg3.isEmpty, \"Message 3 should not be empty\")\n        #expect(msg3 == expectedCiphertext3, \"Message 3 ciphertext should match expected value. Got: \\(msg3.hexString()), Expected: \\(expectedCiphertext3.hexString())\")\n        \n        let decrypted3 = try responderHandshake.readMessage(msg3)\n        #expect(decrypted3 == payload3, \"Message 3: Decrypted payload should match original\")\n        \n        // Verify handshake hash\n        let initiatorHash = initiatorHandshake.getHandshakeHash()\n        let responderHash = responderHandshake.getHandshakeHash()\n        \n        #expect(initiatorHash == responderHash, \"Initiator and responder hashes should match\")\n        \n        if let expectedHash = expectedHash {\n            #expect(\n                initiatorHash == expectedHash,\n                \"Handshake hash should match expected value from test vector. Got: \\(initiatorHash.hexString()), Expected: \\(expectedHash.hexString())\")\n        }\n        \n        // Get transport ciphers\n        let (initSend, initRecv, _) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false)\n        let (respSend, respRecv, _) = try responderHandshake.getTransportCiphers(useExtractedNonce: false)\n\n        // Test transport messages (messages after the 3 handshake messages)\n        for index in 3..<testVector.messages.count {\n            let testMsg = testVector.messages[index]\n            guard let payload = Data(hex: testMsg.payload),\n                  let expectedCiphertext = Data(hex: testMsg.ciphertext) else {\n                throw NSError(\n                    domain: \"NoiseTests\", code: 4,\n                    userInfo: [\n                        NSLocalizedDescriptionKey:\n                            \"Message \\(index + 1): Failed to parse payload hex\"\n                    ])\n            }\n            \n            // Alternate between responder and initiator sending\n            // Responder sends first transport message (since initiator sent last handshake message)\n            let (sender, receiver): (NoiseCipherState, NoiseCipherState)\n            let transportIndex = index - 3\n            if transportIndex % 2 == 0 {\n                // Even transport messages: responder sends\n                sender = respSend\n                receiver = initRecv\n            } else {\n                // Odd transport messages: initiator sends\n                sender = initSend\n                receiver = respRecv\n            }\n            \n            // Encrypt and validate ciphertext matches expected value\n            let ciphertext = try sender.encrypt(plaintext: payload)\n            #expect(\n                ciphertext == expectedCiphertext,\n                \"Message \\(index + 1) ciphertext should match expected value. Got: \\(ciphertext.hexString()), Expected: \\(expectedCiphertext.hexString())\")\n\n            // Decrypt and validate payload\n            let decrypted = try receiver.decrypt(ciphertext: ciphertext)\n            #expect(\n                decrypted == payload,\n                \"Message \\(index + 1): Decrypted payload should match original\")\n        }\n    }\n\n    // MARK: - DH Shared Secret Clearing Tests\n\n    @Test func secureClearCalledDuringHandshake() throws {\n        // Use TrackingMockKeychain to verify secureClear is called\n        let trackingKeychain = TrackingMockKeychain()\n\n        let aliceKey = Curve25519.KeyAgreement.PrivateKey()\n        let bobKey = Curve25519.KeyAgreement.PrivateKey()\n\n        let alice = NoiseSession(\n            peerID: PeerID(str: \"alice-test\"),\n            role: .initiator,\n            keychain: trackingKeychain,\n            localStaticKey: aliceKey\n        )\n\n        let bob = NoiseSession(\n            peerID: PeerID(str: \"bob-test\"),\n            role: .responder,\n            keychain: trackingKeychain,\n            localStaticKey: bobKey\n        )\n\n        // Perform handshake\n        let msg1 = try alice.startHandshake()\n        let msg2 = try bob.processHandshakeMessage(msg1)!\n        let msg3 = try alice.processHandshakeMessage(msg2)!\n        _ = try bob.processHandshakeMessage(msg3)\n\n        // In Noise XX pattern handshake:\n        // - Message 1 (initiator): e token only (no DH)\n        // - Message 2 (responder): e, ee, s, es tokens (2 DH operations: ee, es)\n        // - Message 3 (initiator): s, se tokens (1 DH operation: se)\n        // Total in writeMessage: 3 DH operations (ee, es, se)\n        //\n        // In readMessage (performDHOperation):\n        // - After msg1: no DH\n        // - After msg2: ee, es (2 DH operations)\n        // - After msg3: se (1 DH operation)\n        // Total in performDHOperation: 3 DH operations\n        //\n        // Grand total: 6 DH operations requiring secureClear\n        //\n        // Note: .ss pattern is only used in certain handshake patterns, not XX\n        let expectedMinimumCalls = 6\n        #expect(\n            trackingKeychain.secureClearDataCallCount >= expectedMinimumCalls,\n            \"Expected at least \\(expectedMinimumCalls) secureClear calls for DH secrets, got \\(trackingKeychain.secureClearDataCallCount)\"\n        )\n    }\n\n    @Test func encryptionWorksAfterSecureClear() throws {\n        // Verify that encryption/decryption still works correctly after adding secureClear\n        let trackingKeychain = TrackingMockKeychain()\n\n        let aliceKey = Curve25519.KeyAgreement.PrivateKey()\n        let bobKey = Curve25519.KeyAgreement.PrivateKey()\n\n        let alice = NoiseSession(\n            peerID: PeerID(str: \"alice-test-enc\"),\n            role: .initiator,\n            keychain: trackingKeychain,\n            localStaticKey: aliceKey\n        )\n\n        let bob = NoiseSession(\n            peerID: PeerID(str: \"bob-test-enc\"),\n            role: .responder,\n            keychain: trackingKeychain,\n            localStaticKey: bobKey\n        )\n\n        // Perform handshake\n        let msg1 = try alice.startHandshake()\n        let msg2 = try bob.processHandshakeMessage(msg1)!\n        let msg3 = try alice.processHandshakeMessage(msg2)!\n        _ = try bob.processHandshakeMessage(msg3)\n\n        // Verify both sessions are established\n        #expect(alice.isEstablished())\n        #expect(bob.isEstablished())\n\n        // Verify secureClear was called (basic sanity check)\n        #expect(trackingKeychain.secureClearDataCallCount > 0)\n\n        // Test encryption from Alice to Bob\n        let plaintext1 = \"Hello from Alice after secureClear!\".data(using: .utf8)!\n        let ciphertext1 = try alice.encrypt(plaintext1)\n        let decrypted1 = try bob.decrypt(ciphertext1)\n        #expect(decrypted1 == plaintext1)\n\n        // Test encryption from Bob to Alice\n        let plaintext2 = \"Hello from Bob after secureClear!\".data(using: .utf8)!\n        let ciphertext2 = try bob.encrypt(plaintext2)\n        let decrypted2 = try alice.decrypt(ciphertext2)\n        #expect(decrypted2 == plaintext2)\n\n        // Test multiple messages to verify cipher state is correct\n        for i in 1...10 {\n            let msg = \"Message \\(i) from Alice\".data(using: .utf8)!\n            let cipher = try alice.encrypt(msg)\n            let dec = try bob.decrypt(cipher)\n            #expect(dec == msg)\n        }\n    }\n\n    @Test func secureClearCalledInBothWriteAndReadPaths() throws {\n        // Verify secureClear is called in both writeMessage and readMessage paths\n        // We do this by checking the count increases at each step\n\n        let aliceKeychain = TrackingMockKeychain()\n        let bobKeychain = TrackingMockKeychain()\n\n        let aliceKey = Curve25519.KeyAgreement.PrivateKey()\n        let bobKey = Curve25519.KeyAgreement.PrivateKey()\n\n        let alice = NoiseSession(\n            peerID: PeerID(str: \"alice-paths\"),\n            role: .initiator,\n            keychain: aliceKeychain,\n            localStaticKey: aliceKey\n        )\n\n        let bob = NoiseSession(\n            peerID: PeerID(str: \"bob-paths\"),\n            role: .responder,\n            keychain: bobKeychain,\n            localStaticKey: bobKey\n        )\n\n        // Message 1: Alice writes (e token only, no DH)\n        let msg1 = try alice.startHandshake()\n        let aliceCountAfterMsg1 = aliceKeychain.secureClearDataCallCount\n        // No DH in message 1 for initiator\n        #expect(aliceCountAfterMsg1 == 0, \"No DH secrets in message 1 write\")\n\n        // Bob reads message 1 (no DH) and writes message 2 (ee, es DH operations)\n        let msg2 = try bob.processHandshakeMessage(msg1)!\n        let bobCountAfterMsg2 = bobKeychain.secureClearDataCallCount\n        // Bob should have cleared secrets for: ee (read), es (read), ee (write), es (write)\n        #expect(bobCountAfterMsg2 >= 2, \"Bob should clear DH secrets when processing/writing message 2\")\n\n        // Alice reads message 2 (ee, es) and writes message 3 (se)\n        let msg3 = try alice.processHandshakeMessage(msg2)!\n        let aliceCountAfterMsg3 = aliceKeychain.secureClearDataCallCount\n        // Alice should have cleared: ee (read), es (read), se (write)\n        #expect(aliceCountAfterMsg3 >= 3, \"Alice should clear DH secrets when processing/writing message 3\")\n\n        // Bob reads message 3 (se)\n        _ = try bob.processHandshakeMessage(msg3)\n        let bobFinalCount = bobKeychain.secureClearDataCallCount\n        // Bob should have additionally cleared: se (read)\n        #expect(bobFinalCount > bobCountAfterMsg2, \"Bob should clear DH secrets when processing message 3\")\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Noise/NoiseRateLimiterTests.swift",
    "content": "import XCTest\n@testable import bitchat\n\nfinal class NoiseRateLimiterTests: XCTestCase {\n    func test_allowHandshake_blocksAfterPerPeerLimit() {\n        let limiter = NoiseRateLimiter()\n        let peerID = makePeerID(1)\n\n        for _ in 0..<NoiseSecurityConstants.maxHandshakesPerMinute {\n            XCTAssertTrue(limiter.allowHandshake(from: peerID))\n        }\n\n        XCTAssertFalse(limiter.allowHandshake(from: peerID))\n    }\n\n    func test_allowHandshake_blocksAfterGlobalLimitAcrossPeers() {\n        let limiter = NoiseRateLimiter()\n\n        for index in 0..<NoiseSecurityConstants.maxGlobalHandshakesPerMinute {\n            XCTAssertTrue(limiter.allowHandshake(from: makePeerID(index)))\n        }\n\n        XCTAssertFalse(limiter.allowHandshake(from: makePeerID(10_000)))\n    }\n\n    func test_reset_clearsPerPeerHandshakeLimit() async {\n        let limiter = NoiseRateLimiter()\n        let peerID = makePeerID(7)\n\n        for _ in 0..<NoiseSecurityConstants.maxHandshakesPerMinute {\n            XCTAssertTrue(limiter.allowHandshake(from: peerID))\n        }\n        XCTAssertFalse(limiter.allowHandshake(from: peerID))\n\n        limiter.reset(for: peerID)\n        try? await Task.sleep(nanoseconds: 50_000_000)\n\n        XCTAssertTrue(limiter.allowHandshake(from: peerID))\n    }\n\n    func test_allowMessage_blocksAfterPerPeerLimit() {\n        let limiter = NoiseRateLimiter()\n        let peerID = makePeerID(9)\n\n        for _ in 0..<NoiseSecurityConstants.maxMessagesPerSecond {\n            XCTAssertTrue(limiter.allowMessage(from: peerID))\n        }\n\n        XCTAssertFalse(limiter.allowMessage(from: peerID))\n    }\n\n    func test_resetAll_clearsGlobalHandshakeLimit() async {\n        let limiter = NoiseRateLimiter()\n\n        for index in 0..<NoiseSecurityConstants.maxGlobalHandshakesPerMinute {\n            XCTAssertTrue(limiter.allowHandshake(from: makePeerID(index)))\n        }\n        XCTAssertFalse(limiter.allowHandshake(from: makePeerID(20_000)))\n\n        limiter.resetAll()\n        try? await Task.sleep(nanoseconds: 50_000_000)\n\n        XCTAssertTrue(limiter.allowHandshake(from: makePeerID(20_001)))\n    }\n\n    private func makePeerID(_ value: Int) -> PeerID {\n        PeerID(str: String(format: \"%016x\", value))\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Noise/NoiseTestVectors.json",
    "content": "[\n    {\n        \"protocol_name\": \"Noise_XX_25519_ChaChaPoly_SHA256\",\n        \"init_prologue\": \"4a6f686e2047616c74\",\n        \"init_static\": \"e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1\",\n        \"init_ephemeral\": \"893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a\",\n        \"resp_prologue\": \"4a6f686e2047616c74\",\n        \"resp_static\": \"4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893\",\n        \"resp_ephemeral\": \"bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b\",\n        \"handshake_hash\": \"c8e5f64e846193be2a834104c2a009868d6c9f3bd3c186299888b488b2f1f58e\",\n        \"messages\": [\n            {\n                \"payload\": \"4c756477696720766f6e204d69736573\",\n                \"ciphertext\": \"ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79444c756477696720766f6e204d69736573\"\n            },\n            {\n                \"payload\": \"4d757272617920526f746862617264\",\n                \"ciphertext\": \"95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f14480884381cbad1f276e038c48378ffce2b65285e08d6b68aaa3629a5a8639392490e5b9bd5269c2f1e4f488ed8831161f19b7815528f8982ffe09be9b5c412f8a0db50f8814c7194e83f23dbd8d162c9326ad\"\n            },\n            {\n                \"payload\": \"462e20412e20486179656b\",\n                \"ciphertext\": \"c7195ffacac1307ff99046f219750fc47693e23c3cb08b89c2af808b444850a80ae475b9df0f169ae80a89be0865b57f58c9fea0d4ec82a286427402f113e4b6ae769a1d95941d49b25030\"\n            },\n            {\n                \"payload\": \"4361726c204d656e676572\",\n                \"ciphertext\": \"96763ed773f8e47bb3712f0e29b3060ffc956ffc146cee53d5e1df\"\n            },\n            {\n                \"payload\": \"4a65616e2d426170746973746520536179\",\n                \"ciphertext\": \"3e40f15f6f3a46ae446b253bf8b1d9ffb6ed9b174d272328ff91a7e2e5c79c07f5\"\n            },\n            {\n                \"payload\": \"457567656e2042f6686d20766f6e2042617765726b\",\n                \"ciphertext\": \"eb3f3515110702e047a6c9da4478b6ead94873c11c0f2d710ddb3f09fce024b3a58502ae3f\"\n            }\n        ]\n    },\n    {\n        \"protocol_name\": \"Noise_XX_25519_ChaChaPoly_SHA256\",\n        \"init_prologue\": \"5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e\",\n        \"init_psks\": [],\n        \"init_static\": \"7dec208517a3b81a2861d7a71266d5d6dc944c5a8816634a86fe63198a0148ee\",\n        \"init_ephemeral\": \"a32daf21e93c0131495ce1d903181fde81cc46937daaeb990bae7c992709421e\",\n        \"resp_prologue\": \"5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e\",\n        \"resp_psks\": [],\n        \"resp_static\": \"4d0aed5098e3b4ef20357e9f686ce66204c792b358da2e475017d6c485304881\",\n        \"resp_ephemeral\": \"4eece0f195d026db035ff987597c429d3ad3bcc2944df37d649528951b2a27c5\",\n        \"messages\": [\n            {\n                \"payload\": \"d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34\",\n                \"ciphertext\": \"f9fa868ba97ab8a2686deccfaad5a484ee10a5bb85e3d1dce015a84797f92818d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34\"\n            },\n            {\n                \"payload\": \"d8190a92f7dc0c93dbea9118ba8055751fb7c6590c416ffbd419964132b99a85\",\n                \"ciphertext\": \"8c4e6fdb7d09d501a86f7eca5c234522751706ed409182c05cdf5f827d4dae47b81c6c5f43b025692c24391eefee725c17d8cb0fbe3e4abb8aedf42c4fd2592d4ea48ac08989d6ae8b4adae08b2c34087c808c7aa55a63c02b0fab9e930612336bd43eaea04d3c670a0a146691aa9cc9d357872320dc735dbc48580cffb553db\"\n            },\n            {\n                \"payload\": \"77891b19dcb92ef7c055b672c4a5aa7fdf1c84146b8b303459022729473ce254\",\n                \"ciphertext\": \"933ca6b5ed60df3df66121f0ab49a09e49efa45c613a86a3cecbf4c535cef2f83f72b42837b18e3572f2fdc2b74c331e2368a545cef54bdca081678ab0e9dd5348122459e0c034c851984d88ce610963d43cde6cfe73a67fbd5a63e8bfca96d0\"\n            },\n            {\n                \"payload\": \"d7efdf988072881941db045a42882433817555128fbf5663e56081712ec7d212\",\n                \"ciphertext\": \"54ef0ff0629e1aaa7685a2806ab111cba76b52331f2642276736f415868eacb69ab2577f3bda0cbf72f879685f6ed25f\"\n            },\n            {\n                \"payload\": \"dd7bf01a588bafb52c6cfba952e5d8fe35cc2b3f92b4730ae2474615157345ce\",\n                \"ciphertext\": \"356be70f110306d5c699bb834bb9d58d909e325924dfbec972e406e6f294dc63e1daebefe8a62a334facc8048ab4ad66\"\n            }\n        ]\n    }\n]\n"
  },
  {
    "path": "bitchatTests/Nostr/GeoRelayDirectoryTests.swift",
    "content": "import Foundation\nimport Tor\nimport XCTest\n@testable import bitchat\n\n@MainActor\nfinal class GeoRelayDirectoryTests: XCTestCase {\n    func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() {\n        let csv = \"\"\"\n        relay url,lat,lon\n        wss://one.example/,10,20\n        https://one.example,10,20\n        http://two.example/,11,21\n        invalid row\n        ws://three.example,not-a-lat,22\n        \"\"\"\n\n        let parsed = Set(GeoRelayDirectory.parseCSV(csv))\n\n        XCTAssertEqual(\n            parsed,\n            Set([\n                GeoRelayDirectory.Entry(host: \"one.example\", lat: 10, lon: 20),\n                GeoRelayDirectory.Entry(host: \"two.example\", lat: 11, lon: 21)\n            ])\n        )\n    }\n\n    func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {\n        let harness = makeHarness(\n            cacheCSV: \"\"\"\n            relay url,lat,lon\n            close.example,37.7749,-122.4194\n            medium.example,34.0522,-118.2437\n            far.example,40.7128,-74.0060\n            \"\"\"\n        )\n        let directory = GeoRelayDirectory(dependencies: harness.dependencies)\n\n        XCTAssertEqual(\n            directory.closestRelays(toLat: 37.78, lon: -122.41, count: 2),\n            [\"wss://close.example\", \"wss://medium.example\"]\n        )\n        XCTAssertEqual(\n            directory.closestRelays(toLat: 37.78, lon: -122.41, count: 10),\n            [\"wss://close.example\", \"wss://medium.example\", \"wss://far.example\"]\n        )\n\n        let geohash = Geohash.encode(latitude: 37.78, longitude: -122.41, precision: 6)\n        XCTAssertEqual(\n            directory.closestRelays(toGeohash: geohash, count: 2),\n            [\"wss://close.example\", \"wss://medium.example\"]\n        )\n    }\n\n    func test_loadLocalEntries_prefersCacheThenBundleThenWorkingDirectory() {\n        let cacheHarness = makeHarness(\n            cacheCSV: \"\"\"\n            relay url,lat,lon\n            cache.example,1,1\n            \"\"\",\n            bundleCSV: \"\"\"\n            relay url,lat,lon\n            bundle.example,2,2\n            \"\"\",\n            workingDirectoryCSV: \"\"\"\n            relay url,lat,lon\n            cwd.example,3,3\n            \"\"\"\n        )\n        XCTAssertEqual(\n            GeoRelayDirectory(dependencies: cacheHarness.dependencies).entries,\n            [GeoRelayDirectory.Entry(host: \"cache.example\", lat: 1, lon: 1)]\n        )\n\n        let bundleHarness = makeHarness(\n            cacheCSV: \"invalid\",\n            bundleCSV: \"\"\"\n            relay url,lat,lon\n            bundle.example,2,2\n            \"\"\",\n            workingDirectoryCSV: \"\"\"\n            relay url,lat,lon\n            cwd.example,3,3\n            \"\"\"\n        )\n        XCTAssertEqual(\n            GeoRelayDirectory(dependencies: bundleHarness.dependencies).entries,\n            [GeoRelayDirectory.Entry(host: \"bundle.example\", lat: 2, lon: 2)]\n        )\n\n        let cwdHarness = makeHarness(\n            cacheCSV: nil,\n            bundleCSV: \"invalid\",\n            workingDirectoryCSV: \"\"\"\n            relay url,lat,lon\n            cwd.example,3,3\n            \"\"\"\n        )\n        XCTAssertEqual(\n            GeoRelayDirectory(dependencies: cwdHarness.dependencies).entries,\n            [GeoRelayDirectory.Entry(host: \"cwd.example\", lat: 3, lon: 3)]\n        )\n    }\n\n    func test_prefetchIfNeeded_skipsWhenFetchIntervalHasNotElapsed() async {\n        let harness = makeHarness(fetchCSV: \"\"\"\n        relay url,lat,lon\n        one.example,1,1\n        \"\"\")\n        harness.userDefaults.set(harness.clock.now, forKey: \"georelay.lastFetchAt\")\n        let directory = GeoRelayDirectory(dependencies: harness.dependencies)\n\n        directory.prefetchIfNeeded()\n        try? await Task.sleep(nanoseconds: 20_000_000)\n\n        let requestCount = await harness.fetcher.recordedRequestCount()\n        XCTAssertEqual(requestCount, 0)\n        XCTAssertFalse(directory.debugHasRetryTask)\n    }\n\n    func test_prefetchIfNeeded_successUpdatesEntriesPersistsCacheAndSkipsImmediateForcedRefetch() async {\n        let csv = \"\"\"\n        relay url,lat,lon\n        refreshed.example,12,34\n        \"\"\"\n        let harness = makeHarness(fetchCSV: csv)\n        let directory = GeoRelayDirectory(dependencies: harness.dependencies)\n\n        directory.prefetchIfNeeded()\n        let refreshed = await waitUntil {\n            directory.entries == [GeoRelayDirectory.Entry(host: \"refreshed.example\", lat: 12, lon: 34)]\n        }\n        XCTAssertTrue(refreshed)\n        let requestCount = await harness.fetcher.recordedRequestCount()\n        XCTAssertEqual(requestCount, 1)\n        XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], csv.data(using: .utf8))\n        XCTAssertEqual(harness.userDefaults.object(forKey: \"georelay.lastFetchAt\") as? Date, harness.clock.now)\n        XCTAssertEqual(directory.debugRetryAttempt, 0)\n        XCTAssertFalse(directory.debugHasRetryTask)\n\n        directory.prefetchIfNeeded(force: true)\n        try? await Task.sleep(nanoseconds: 20_000_000)\n        let forcedRequestCount = await harness.fetcher.recordedRequestCount()\n        XCTAssertEqual(forcedRequestCount, 1)\n    }\n\n    func test_prefetchIfNeeded_runsRemoteFetchOffMainThread() async {\n        var factoryThreadFlags: [Bool] = []\n        let threadRecorder = MainThreadRecorder()\n        let harness = makeHarness(\n            fetchCSV: \"\"\"\n            relay url,lat,lon\n            background.example,8,9\n            \"\"\",\n            fetchFactoryObserver: {\n                factoryThreadFlags.append(isExecutingOnMainThread())\n            },\n            fetchObserver: {\n                await threadRecorder.record(isExecutingOnMainThread())\n            }\n        )\n        let directory = GeoRelayDirectory(dependencies: harness.dependencies)\n\n        directory.prefetchIfNeeded()\n\n        let refreshed = await waitUntil {\n            directory.entries == [GeoRelayDirectory.Entry(host: \"background.example\", lat: 8, lon: 9)]\n        }\n        XCTAssertTrue(refreshed)\n        XCTAssertEqual(factoryThreadFlags, [true])\n        let recordedValues = await threadRecorder.recordedValues()\n        XCTAssertEqual(recordedValues, [false])\n    }\n\n    func test_prefetchIfNeeded_failureSchedulesRetryAndRecoversOnNextFetch() async {\n        let csv = \"\"\"\n        relay url,lat,lon\n        retry.example,5,6\n        \"\"\"\n        let harness = makeHarness(\n            fetchResults: [\n                .failure(GeoRelayTestError.network),\n                .success(csv.data(using: .utf8)!)\n            ]\n        )\n        let directory = GeoRelayDirectory(dependencies: harness.dependencies)\n\n        directory.prefetchIfNeeded()\n\n        let recovered = await waitUntil {\n            directory.entries == [GeoRelayDirectory.Entry(host: \"retry.example\", lat: 5, lon: 6)]\n        }\n        XCTAssertTrue(recovered)\n        let requestCount = await harness.fetcher.recordedRequestCount()\n        let retryDelays = await harness.retryRecorder.recordedDelays()\n        XCTAssertEqual(requestCount, 2)\n        XCTAssertEqual(retryDelays, [5])\n        XCTAssertEqual(directory.debugRetryAttempt, 0)\n        XCTAssertFalse(directory.debugHasRetryTask)\n    }\n\n    func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {\n        let activeNotification = Notification.Name(\"GeoRelayDirectoryTests.didBecomeActive\")\n        let harness = makeHarness(\n            fetchCSV: \"\"\"\n            relay url,lat,lon\n            observer.example,1,2\n            \"\"\",\n            autoStart: true,\n            activeNotificationName: activeNotification\n        )\n        var directory: GeoRelayDirectory? = GeoRelayDirectory(dependencies: harness.dependencies)\n        let initialFetch = await waitUntil {\n            await harness.fetcher.recordedRequestCount() == 1\n        }\n        XCTAssertTrue(initialFetch)\n        XCTAssertEqual(directory?.debugObserverCount, 2)\n\n        harness.clock.now = harness.clock.now.addingTimeInterval(6)\n        harness.notificationCenter.post(name: .TorDidBecomeReady, object: nil)\n        let torTriggered = await waitUntil {\n            await harness.fetcher.recordedRequestCount() == 2\n        }\n        XCTAssertTrue(torTriggered)\n\n        harness.clock.now = harness.clock.now.addingTimeInterval(61)\n        harness.notificationCenter.post(name: activeNotification, object: nil)\n        let activeTriggered = await waitUntil {\n            await harness.fetcher.recordedRequestCount() == 3\n        }\n        XCTAssertTrue(activeTriggered)\n\n        weak var weakDirectory: GeoRelayDirectory?\n        weakDirectory = directory\n        directory = nil\n        XCTAssertNil(weakDirectory)\n    }\n\n    private func makeHarness(\n        cacheCSV: String? = nil,\n        bundleCSV: String? = nil,\n        workingDirectoryCSV: String? = nil,\n        fetchCSV: String? = nil,\n        fetchResults: [Result<Data, Error>] = [],\n        fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,\n        fetchObserver: (@Sendable () async -> Void)? = nil,\n        autoStart: Bool = false,\n        activeNotificationName: Notification.Name? = nil\n    ) -> GeoRelayHarness {\n        let userDefaultsSuite = \"GeoRelayDirectoryTests.\\(UUID().uuidString)\"\n        let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!\n        userDefaults.removePersistentDomain(forName: userDefaultsSuite)\n\n        let notificationCenter = NotificationCenter()\n        let clock = MutableGeoClock(now: Date(timeIntervalSince1970: 1_700_000_000))\n        let fileStore = InMemoryFileStore()\n        let cacheURL = URL(fileURLWithPath: \"/tmp/\\(UUID().uuidString)-cache.csv\")\n        let bundleURL = URL(fileURLWithPath: \"/tmp/\\(UUID().uuidString)-bundle.csv\")\n        let cwd = \"/tmp/\\(UUID().uuidString)-cwd\"\n        let cwdURL = URL(fileURLWithPath: cwd).appendingPathComponent(\"relays/online_relays_gps.csv\")\n\n        if let cacheCSV {\n            fileStore.dataByURL[cacheURL] = Data(cacheCSV.utf8)\n        }\n        if let bundleCSV {\n            fileStore.dataByURL[bundleURL] = Data(bundleCSV.utf8)\n        }\n        if let workingDirectoryCSV {\n            fileStore.dataByURL[cwdURL] = Data(workingDirectoryCSV.utf8)\n        }\n\n        let defaultFetchData = Data((fetchCSV ?? bundleCSV ?? cacheCSV ?? \"relay url,lat,lon\\nfallback.example,0,0\\n\").utf8)\n        let fetcher = FetchProbe(responses: fetchResults, defaultData: defaultFetchData)\n        let retryRecorder = RetryDelayRecorder()\n\n        let dependencies = GeoRelayDirectoryDependencies(\n            userDefaults: userDefaults,\n            notificationCenter: notificationCenter,\n            now: { clock.now },\n            remoteURL: URL(string: \"https://example.com/nostr_relays.csv\")!,\n            fetchInterval: 60,\n            refreshCheckInterval: 0,\n            retryInitialSeconds: 5,\n            retryMaxSeconds: 40,\n            awaitTorReady: { true },\n            makeFetchData: {\n                fetchFactoryObserver?()\n                return { request in\n                    await fetchObserver?()\n                    return try await fetcher.fetch(request)\n                }\n            },\n            readData: { url in\n                fileStore.dataByURL[url]\n            },\n            writeData: { data, url in\n                fileStore.dataByURL[url] = data\n            },\n            cacheURL: { cacheURL },\n            bundledCSVURLs: bundleCSV == nil ? { [] } : { [bundleURL] },\n            currentDirectoryPath: workingDirectoryCSV == nil ? { nil } : { cwd },\n            retrySleep: { delay in\n                await retryRecorder.record(delay)\n            },\n            activeNotificationName: activeNotificationName,\n            autoStart: autoStart\n        )\n\n        return GeoRelayHarness(\n            dependencies: dependencies,\n            clock: clock,\n            fileStore: fileStore,\n            fetcher: fetcher,\n            retryRecorder: retryRecorder,\n            userDefaults: userDefaults,\n            notificationCenter: notificationCenter,\n            cacheURL: cacheURL\n        )\n    }\n\n    private func waitUntil(\n        timeout: TimeInterval = 1.0,\n        condition: @escaping @MainActor () async -> Bool\n    ) async -> Bool {\n        let deadline = Date().addingTimeInterval(timeout)\n        while Date() < deadline {\n            if await condition() {\n                return true\n            }\n            try? await Task.sleep(nanoseconds: 10_000_000)\n        }\n        return await condition()\n    }\n}\n\nprivate struct GeoRelayHarness {\n    let dependencies: GeoRelayDirectoryDependencies\n    let clock: MutableGeoClock\n    let fileStore: InMemoryFileStore\n    let fetcher: FetchProbe\n    let retryRecorder: RetryDelayRecorder\n    let userDefaults: UserDefaults\n    let notificationCenter: NotificationCenter\n    let cacheURL: URL\n}\n\nprivate final class MutableGeoClock {\n    var now: Date\n\n    init(now: Date) {\n        self.now = now\n    }\n}\n\nprivate final class InMemoryFileStore {\n    var dataByURL: [URL: Data] = [:]\n}\n\nprivate actor FetchProbe {\n    private var responses: [Result<Data, Error>]\n    private let defaultData: Data\n    private(set) var requestCount = 0\n\n    init(responses: [Result<Data, Error>], defaultData: Data) {\n        self.responses = responses\n        self.defaultData = defaultData\n    }\n\n    func fetch(_ request: URLRequest) async throws -> Data {\n        _ = request\n        requestCount += 1\n        if !responses.isEmpty {\n            return try responses.removeFirst().get()\n        }\n        return defaultData\n    }\n\n    func recordedRequestCount() -> Int {\n        requestCount\n    }\n}\n\nprivate actor RetryDelayRecorder {\n    private(set) var delays: [TimeInterval] = []\n\n    func record(_ delay: TimeInterval) {\n        delays.append(delay)\n    }\n\n    func recordedDelays() -> [TimeInterval] {\n        delays\n    }\n}\n\nprivate actor MainThreadRecorder {\n    private var values: [Bool] = []\n\n    func record(_ value: Bool) {\n        values.append(value)\n    }\n\n    func recordedValues() -> [Bool] {\n        values\n    }\n}\n\nprivate enum GeoRelayTestError: Error {\n    case network\n}\n\nprivate func isExecutingOnMainThread() -> Bool {\n    Thread.isMainThread\n}\n"
  },
  {
    "path": "bitchatTests/NostrProtocolTests.swift",
    "content": "//\n// NostrProtocolTests.swift\n// bitchatTests\n//\n// Tests for NIP-17 gift-wrapped private messages\n//\n\nimport Testing\nimport CryptoKit\nimport Foundation\n@testable import bitchat\n\nstruct NostrProtocolTests {\n    \n    @Test func nip17MessageRoundTrip() throws {\n        // Create sender and recipient identities\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        \n        print(\"Sender pubkey: \\(sender.publicKeyHex)\")\n        print(\"Recipient pubkey: \\(recipient.publicKeyHex)\")\n        \n        // Create a test message\n        let originalContent = \"Hello from NIP-17 test!\"\n        \n        // Create encrypted gift wrap\n        let giftWrap = try NostrProtocol.createPrivateMessage(\n            content: originalContent,\n            recipientPubkey: recipient.publicKeyHex,\n            senderIdentity: sender\n        )\n        \n        print(\"Gift wrap created with ID: \\(giftWrap.id)\")\n        print(\"Gift wrap pubkey: \\(giftWrap.pubkey)\")\n        \n        // Decrypt the gift wrap\n        let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(\n            giftWrap: giftWrap,\n            recipientIdentity: recipient\n        )\n        \n        // Verify\n        #expect(decryptedContent == originalContent)\n        #expect(senderPubkey == sender.publicKeyHex)\n        \n        // Verify timestamp is reasonable (within last minute)\n        let messageDate = Date(timeIntervalSince1970: TimeInterval(timestamp))\n        let timeDiff = abs(messageDate.timeIntervalSinceNow)\n        #expect(timeDiff < 60, \"Message timestamp should be recent\")\n        \n        print(\"✅ Successfully decrypted message: '\\(decryptedContent)' from \\(senderPubkey) at \\(messageDate)\")\n    }\n    \n    @Test func giftWrapUsesUniqueEphemeralKeys() throws {\n        // Create identities\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        \n        // Create two messages\n        let message1 = try NostrProtocol.createPrivateMessage(\n            content: \"Message 1\",\n            recipientPubkey: recipient.publicKeyHex,\n            senderIdentity: sender\n        )\n        \n        let message2 = try NostrProtocol.createPrivateMessage(\n            content: \"Message 2\",\n            recipientPubkey: recipient.publicKeyHex,\n            senderIdentity: sender\n        )\n        \n        // Gift wrap pubkeys should be different (unique ephemeral keys)\n        #expect(message1.pubkey != message2.pubkey)\n        \n        print(\"Message 1 gift wrap pubkey: \\(message1.pubkey)\")\n        print(\"Message 2 gift wrap pubkey: \\(message2.pubkey)\")\n        \n        // Both should decrypt successfully\n        let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(\n            giftWrap: message1,\n            recipientIdentity: recipient\n        )\n        let (content2, _, _) = try NostrProtocol.decryptPrivateMessage(\n            giftWrap: message2,\n            recipientIdentity: recipient\n        )\n        \n        #expect(content1 == \"Message 1\")\n        #expect(content2 == \"Message 2\")\n    }\n    \n    @Test func decryptionFailsWithWrongRecipient() throws {\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        let wrongRecipient = try NostrIdentity.generate()\n        \n        // Create message for recipient\n        let giftWrap = try NostrProtocol.createPrivateMessage(\n            content: \"Secret message\",\n            recipientPubkey: recipient.publicKeyHex,\n            senderIdentity: sender\n        )\n        \n        // Try to decrypt with wrong recipient\n        if #available(macOS 14.4, iOS 17.4, *) {\n            #expect(throws: CryptoKitError.authenticationFailure) {\n                try NostrProtocol.decryptPrivateMessage(\n                    giftWrap: giftWrap,\n                    recipientIdentity: wrongRecipient\n                )\n            }\n        } else {\n            #expect(throws: (any Error).self) {\n                try NostrProtocol.decryptPrivateMessage(\n                    giftWrap: giftWrap,\n                    recipientIdentity: wrongRecipient\n                )\n            }\n        }\n    }\n\n    func testAckRoundTripNIP44V2_Delivered() throws {\n        // Identities\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n\n        // Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)\n        let messageID = \"TEST-MSG-DELIVERED-1\"\n        let senderPeerID = PeerID(str: \"0123456789abcdef\") // 8-byte hex peer ID\n\n        let embedded = try #require(\n            NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),\n            \"Failed to embed delivered ack\"\n        )\n\n        // Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)\n        let giftWrap = try NostrProtocol.createPrivateMessage(\n            content: embedded,\n            recipientPubkey: recipient.publicKeyHex,\n            senderIdentity: sender\n        )\n\n        // Ensure v2 format was used for ciphertext\n        #expect(giftWrap.content.hasPrefix(\"v2:\"))\n\n        // Decrypt as recipient\n        let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(\n            giftWrap: giftWrap,\n            recipientIdentity: recipient\n        )\n\n        // Verify sender is correct\n        #expect(senderPubkey == sender.publicKeyHex)\n\n        // Parse BitChat payload\n        #expect(content.hasPrefix(\"bitchat1:\"))\n        let base64url = String(content.dropFirst(\"bitchat1:\".count))\n        let packetData = try #require(Self.base64URLDecode(base64url))\n        let packet = try #require(BitchatPacket.from(packetData), \"Failed to decode bitchat packet\")\n        \n        #expect(packet.type == MessageType.noiseEncrypted.rawValue)\n        let payload = try #require(NoisePayload.decode(packet.payload), \"Failed to decode NoisePayload\")\n        \n        switch payload.type {\n        case .delivered:\n            let mid = String(data: payload.data, encoding: .utf8)\n            #expect(mid == messageID)\n        default:\n            Issue.record(\"Unexpected payload type: \\(payload.type)\")\n        }\n    }\n\n    @Test func ackRoundTripNIP44V2_ReadReceipt() throws {\n        // Identities\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        \n        let messageID = \"TEST-MSG-READ-1\"\n        let senderPeerID = PeerID(str: \"fedcba9876543210\") // 8-byte hex peer ID\n        let embedded = try #require(\n            NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),\n            \"Failed to embed read ack\"\n        )\n\n        let giftWrap = try NostrProtocol.createPrivateMessage(\n            content: embedded,\n            recipientPubkey: recipient.publicKeyHex,\n            senderIdentity: sender\n        )\n\n        #expect(giftWrap.content.hasPrefix(\"v2:\"))\n\n        let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(\n            giftWrap: giftWrap,\n            recipientIdentity: recipient\n        )\n        #expect(senderPubkey == sender.publicKeyHex)\n\n        #expect(content.hasPrefix(\"bitchat1:\"))\n        let base64url = String(content.dropFirst(\"bitchat1:\".count))\n        let packetData = try #require(Self.base64URLDecode(base64url))\n        let packet = try #require(BitchatPacket.from(packetData), \"Failed to decode bitchat packet\")\n        \n        #expect(packet.type == MessageType.noiseEncrypted.rawValue)\n        let payload = try #require(NoisePayload.decode(packet.payload), \"Failed to decode NoisePayload\")\n        \n        switch payload.type {\n        case .readReceipt:\n            let mid = String(data: payload.data, encoding: .utf8)\n            #expect(mid == messageID)\n        default:\n            Issue.record(\"Unexpected payload type: \\(payload.type)\")\n        }\n    }\n\n    @Test func nostrEventSignatureVerification_roundTrip() throws {\n        let identity = try NostrIdentity.generate()\n        let event = NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .ephemeralEvent,\n            tags: [],\n            content: \"Signed event\"\n        )\n        let signed = try event.sign(with: identity.schnorrSigningKey())\n        #expect(signed.isValidSignature())\n    }\n\n    @Test func nostrEventSignatureVerification_detectsTamper() throws {\n        let identity = try NostrIdentity.generate()\n        let event = NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .ephemeralEvent,\n            tags: [],\n            content: \"Original\"\n        )\n        var signed = try event.sign(with: identity.schnorrSigningKey())\n        signed.id = \"deadbeef\"\n        #expect(!signed.isValidSignature())\n    }\n\n    @Test func geohashNotesSingleFilter_encodesExpectedTagShape() throws {\n        let since = Date(timeIntervalSince1970: 1_234_567)\n        let filter = NostrFilter.geohashNotes(\"u4pruyd\", since: since, limit: 42)\n        let data = try JSONEncoder().encode(filter)\n        let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])\n\n        #expect(object[\"kinds\"] as? [Int] == [1])\n        #expect(object[\"#g\"] as? [String] == [\"u4pruyd\"])\n        #expect(object[\"since\"] as? Int == 1_234_567)\n        #expect(object[\"limit\"] as? Int == 42)\n    }\n\n    // MARK: - Helpers\n    private static func base64URLDecode(_ s: String) -> Data? {\n        var str = s.replacingOccurrences(of: \"-\", with: \"+\").replacingOccurrences(of: \"_\", with: \"/\")\n        let rem = str.count % 4\n        if rem > 0 { str.append(String(repeating: \"=\", count: 4 - rem)) }\n        return Data(base64Encoded: str)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/NotificationBlockingTests.swift",
    "content": "//\n// NotificationBlockingTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n// BCH-01-012: Tests for notification blocking feature\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\nstruct NotificationBlockingTests {\n\n    // MARK: - Nostr Blocking Tests\n\n    @Test(\"isNostrBlocked returns true for blocked pubkeys\")\n    func isNostrBlocked_returnsTrueForBlockedPubkey() {\n        let keychain = MockKeychain()\n        let manager = MockIdentityManager(keychain)\n\n        let testPubkey = \"abc123def456\".lowercased()\n\n        // Initially not blocked\n        #expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == false)\n\n        // Block the pubkey\n        manager.setNostrBlocked(testPubkey, isBlocked: true)\n\n        // Now should be blocked\n        #expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == true)\n\n        // Unblock\n        manager.setNostrBlocked(testPubkey, isBlocked: false)\n        #expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == false)\n    }\n\n    @Test(\"isBlocked returns true for blocked fingerprints\")\n    func isBlocked_returnsTrueForBlockedFingerprint() {\n        let keychain = MockKeychain()\n        let manager = MockIdentityManager(keychain)\n\n        let testFingerprint = \"fingerprint123\"\n\n        // Initially not blocked\n        #expect(manager.isBlocked(fingerprint: testFingerprint) == false)\n\n        // Block the fingerprint\n        manager.setBlocked(testFingerprint, isBlocked: true)\n\n        // Now should be blocked\n        #expect(manager.isBlocked(fingerprint: testFingerprint) == true)\n\n        // Unblock\n        manager.setBlocked(testFingerprint, isBlocked: false)\n        #expect(manager.isBlocked(fingerprint: testFingerprint) == false)\n    }\n\n    @Test(\"getBlockedNostrPubkeys returns all blocked pubkeys\")\n    func getBlockedNostrPubkeys_returnsAllBlocked() {\n        let keychain = MockKeychain()\n        let manager = MockIdentityManager(keychain)\n\n        let pubkey1 = \"pubkey1\".lowercased()\n        let pubkey2 = \"pubkey2\".lowercased()\n        let pubkey3 = \"pubkey3\".lowercased()\n\n        manager.setNostrBlocked(pubkey1, isBlocked: true)\n        manager.setNostrBlocked(pubkey2, isBlocked: true)\n        manager.setNostrBlocked(pubkey3, isBlocked: true)\n\n        let blocked = manager.getBlockedNostrPubkeys()\n\n        #expect(blocked.count == 3)\n        #expect(blocked.contains(pubkey1))\n        #expect(blocked.contains(pubkey2))\n        #expect(blocked.contains(pubkey3))\n    }\n\n    // MARK: - Message Blocking Tests\n\n    @Test(\"BitchatMessage with blocked sender is identified\")\n    func bitchatMessage_blockedSenderIdentified() {\n        let keychain = MockKeychain()\n        let manager = MockIdentityManager(keychain)\n\n        let blockedFingerprint = \"blocked_fingerprint_123\"\n        manager.setBlocked(blockedFingerprint, isBlocked: true)\n\n        #expect(manager.isBlocked(fingerprint: blockedFingerprint) == true)\n    }\n\n    @Test(\"Case insensitive blocking for Nostr pubkeys\")\n    func nostrBlocking_caseInsensitive() {\n        let keychain = MockKeychain()\n        let manager = MockIdentityManager(keychain)\n\n        let pubkeyLower = \"abc123def456\"\n\n        // Block lowercase\n        manager.setNostrBlocked(pubkeyLower, isBlocked: true)\n\n        // Check lowercase is blocked\n        #expect(manager.isNostrBlocked(pubkeyHexLowercased: pubkeyLower) == true)\n\n        // Note: The API expects lowercased input, so callers must normalize\n        // This test verifies the contract that pubkeys should be lowercased before checking\n        // The fix in ChatViewModel+Nostr.swift normalizes via event.pubkey.lowercased()\n    }\n}\n"
  },
  {
    "path": "bitchatTests/NotificationStreamAssemblerTests.swift",
    "content": "import Testing\nimport Foundation\n@testable import bitchat\n\nstruct NotificationStreamAssemblerTests {\n    private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket {\n        let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])\n        return BitchatPacket(\n            type: MessageType.message.rawValue,\n            senderID: sender,\n            recipientID: nil,\n            timestamp: timestamp,\n            payload: Data([0xDE, 0xAD, 0xBE, 0xEF]),\n            signature: nil,\n            ttl: 3\n        )\n    }\n\n    @Test func assemblesSingleFrameAcrossChunks() throws {\n        var assembler = NotificationStreamAssembler()\n        let packet = makePacket()\n        let frame = try #require(packet.toBinaryData(padding: false), \"Failed to encode packet\")\n\n        #expect(BinaryProtocol.decode(frame) != nil)\n        let payloadLen = (Int(frame[12]) << 8) | Int(frame[13])\n        #expect(payloadLen == packet.payload.count)\n\n        let splitIndex = min(20, max(1, frame.count / 2))\n        let first = frame.prefix(splitIndex)\n        let second = frame.suffix(from: splitIndex)\n        #expect(first.count + second.count == frame.count)\n\n        var result = assembler.append(first)\n        #expect(result.frames.isEmpty)\n        #expect(result.droppedPrefixes.isEmpty)\n        #expect(!result.reset)\n\n        result = assembler.append(second)\n        #expect(result.frames.count == 1)\n        #expect(result.droppedPrefixes.isEmpty)\n        #expect(!result.reset)\n\n        let frameData = try #require(result.frames.first, \"Missing frame data\")\n        #expect(frameData.count == frame.count)\n        \n        let decoded = try #require(BinaryProtocol.decode(frameData), \"Failed to decode frame\")\n        #expect(decoded.type == packet.type)\n        #expect(decoded.payload == packet.payload)\n        #expect(decoded.senderID == packet.senderID)\n        #expect(decoded.timestamp == packet.timestamp)\n\n        var directAssembler = NotificationStreamAssembler()\n        let directResult = directAssembler.append(frame)\n        #expect(directResult.frames.first?.count == frame.count)\n    }\n\n    @Test func assemblesMultipleFramesSequentially() throws {\n        var assembler = NotificationStreamAssembler()\n        let packet1 = makePacket(timestamp: 0xABC)\n        let packet2 = makePacket(timestamp: 0xDEF)\n\n        let frame1 = try #require(packet1.toBinaryData(padding: false), \"Failed to encode packet\")\n        let frame2 = try #require(packet2.toBinaryData(padding: false), \"Failed to encode packet\")\n\n        var combined = Data()\n        combined.append(frame1)\n        combined.append(frame2)\n        let firstChunk = combined.prefix(20)\n        let secondChunk = combined.suffix(from: 20)\n\n        var result = assembler.append(firstChunk)\n        #expect(result.frames.isEmpty)\n\n        result = assembler.append(secondChunk)\n        #expect(result.frames.count == 2)\n        \n        let decoded1 = try #require(BinaryProtocol.decode(result.frames[0]), \"Failed to decode frame\")\n        let decoded2 = try #require(BinaryProtocol.decode(result.frames[1]), \"Failed to decode frame\")\n        #expect(decoded1.timestamp == packet1.timestamp)\n        #expect(decoded2.timestamp == packet2.timestamp)\n    }\n\n    @Test func dropsInvalidPrefixByte() throws {\n        var assembler = NotificationStreamAssembler()\n        let packet = makePacket(timestamp: 0xF00)\n        let frame = try #require(packet.toBinaryData(padding: false), \"Failed to encode packet\")\n        var noisyFrame = Data([0x00])\n        noisyFrame.append(frame)\n\n        let result = assembler.append(noisyFrame)\n        #expect(result.droppedPrefixes == [0x00])\n        #expect(result.frames.count == 1)\n        #expect(result.reset == false)\n\n        let decoded = try #require(BinaryProtocol.decode(result.frames[0]), \"Failed to decode frame after drop\")\n        #expect(decoded.timestamp == packet.timestamp)\n    }\n\n    func testAssemblesCompressedLargeFrame() throws {\n        var assembler = NotificationStreamAssembler()\n\n        // Keep the fixture below FileTransferLimits.maxPayloadBytes so encoding succeeds while still exercising compression.\n        let largeContent = Data(repeating: 0x41, count: 600_000)\n        let filePacket = BitchatFilePacket(\n            fileName: \"large.bin\",\n            fileSize: UInt64(largeContent.count),\n            mimeType: \"application/octet-stream\",\n            content: largeContent\n        )\n        let tlvPayload = try #require(filePacket.encode(), \"Failed to encode file packet\")\n\n        let senderID = Data(repeating: 0xAA, count: BinaryProtocol.senderIDSize)\n        let packet = BitchatPacket(\n            type: MessageType.fileTransfer.rawValue,\n            senderID: senderID,\n            recipientID: nil,\n            timestamp: 0x010203040506,\n            payload: tlvPayload,\n            signature: nil,\n            ttl: 3,\n            version: 2\n        )\n\n        let frame = try #require(packet.toBinaryData(padding: false), \"Failed to encode packet frame\")\n\n        #expect(BinaryProtocol.Offsets.flags < frame.count)\n        let flags = frame[frame.startIndex + BinaryProtocol.Offsets.flags]\n        #expect((flags & BinaryProtocol.Flags.isCompressed) != 0, \"Frame should be compressed for large payloads\")\n\n        let splitIndex = min(4096, frame.count / 2)\n        var result = assembler.append(frame.prefix(splitIndex))\n        #expect(result.frames.isEmpty)\n\n        result = assembler.append(frame.suffix(from: splitIndex))\n        #expect(result.frames.count == 1)\n        #expect(result.droppedPrefixes.isEmpty)\n        #expect(result.reset == false)\n\n        let assembled = try #require(result.frames.first, \"Missing assembled frame\")\n        #expect(assembled.count == frame.count)\n\n        let decodedPacket = try #require(BinaryProtocol.decode(assembled), \"Failed to decode compressed frame\")\n        #expect(decodedPacket.payload.count == tlvPayload.count)\n\n        let decodedFile = try #require(BitchatFilePacket.decode(decodedPacket.payload), \"Failed to decode TLV payload\")\n        #expect(decodedFile.fileName == filePacket.fileName)\n        #expect(decodedFile.mimeType == filePacket.mimeType)\n        #expect(decodedFile.content.count == largeContent.count)\n        #expect(decodedFile.content.prefix(32) == largeContent.prefix(32))\n    }\n}\n"
  },
  {
    "path": "bitchatTests/PreviewKeychainManagerTests.swift",
    "content": "import Foundation\nimport Testing\n@testable import bitchat\n\n@Suite(\"PreviewKeychainManager Tests\")\nstruct PreviewKeychainManagerTests {\n\n    @Test(\"Preview keychain manager stores identity and service-scoped data in memory\")\n    func previewKeychainManagerRoundTripsData() {\n        let manager = PreviewKeychainManager()\n        let identityKey = Data([1, 2, 3, 4])\n        let serviceKey = \"preview-service\"\n        let scopedData = Data([9, 8, 7, 6])\n\n        #expect(!manager.verifyIdentityKeyExists())\n        #expect(manager.saveIdentityKey(identityKey, forKey: \"noiseStaticKey\"))\n        #expect(manager.getIdentityKey(forKey: \"noiseStaticKey\") == identityKey)\n        #expect(manager.saveIdentityKey(identityKey, forKey: \"identity_noiseStaticKey\"))\n        #expect(manager.verifyIdentityKeyExists())\n\n        if case .success(let stored) = manager.getIdentityKeyWithResult(forKey: \"noiseStaticKey\") {\n            #expect(stored == identityKey)\n        } else {\n            Issue.record(\"Expected stored preview identity key\")\n        }\n\n        if case .success = manager.saveIdentityKeyWithResult(Data([5, 6, 7]), forKey: \"ed25519SigningKey\") {\n        } else {\n            Issue.record(\"Expected preview keychain save to succeed\")\n        }\n\n        manager.save(key: \"blob\", data: scopedData, service: serviceKey, accessible: nil)\n        #expect(manager.load(key: \"blob\", service: serviceKey) == scopedData)\n        manager.delete(key: \"blob\", service: serviceKey)\n        #expect(manager.load(key: \"blob\", service: serviceKey) == nil)\n\n        var secretData = Data([4, 3, 2, 1])\n        var secretString = \"secret\"\n        manager.secureClear(&secretData)\n        manager.secureClear(&secretString)\n        #expect(secretData == Data([4, 3, 2, 1]))\n        #expect(secretString == \"secret\")\n\n        #expect(manager.deleteIdentityKey(forKey: \"noiseStaticKey\"))\n        #expect(manager.deleteIdentityKey(forKey: \"identity_noiseStaticKey\"))\n        #expect(manager.getIdentityKey(forKey: \"noiseStaticKey\") == nil)\n        #expect(manager.deleteAllKeychainData())\n\n        if case .itemNotFound = manager.getIdentityKeyWithResult(forKey: \"ed25519SigningKey\") {\n        } else {\n            Issue.record(\"Expected preview keychain to be empty after deleteAllKeychainData\")\n        }\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Protocol/BinaryProtocolPaddingTests.swift",
    "content": "//\n// BinaryProtocolPaddingTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Testing\n@testable import bitchat\n\nstruct BinaryProtocolPaddingTests {\n    @Test func padded_vs_unpadded_length() throws {\n        // Use helper to create a small test packet\n        let packet = TestHelpers.createTestPacket()\n        let padded = try #require(BinaryProtocol.encode(packet, padding: true), \"encode padded\")\n        let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), \"encode unpadded\")\n        #expect(padded.count >= unpadded.count, \"Padded frame should be >= unpadded\")\n    }\n\n    @Test func decode_padded_and_unpadded_round_trip() throws {\n        let packet = TestHelpers.createTestPacket()\n\n        let padded = try #require(BinaryProtocol.encode(packet, padding: true), \"encode padded\")\n        let dec1 = try #require(BinaryProtocol.decode(padded), \"decode padded\")\n        #expect(dec1.type == packet.type)\n        #expect(dec1.payload == packet.payload)\n\n        let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), \"encode unpadded\")\n        let dec2 = try #require(BinaryProtocol.decode(unpadded), \"decode unpadded\")\n        #expect(dec2.type == packet.type)\n        #expect(dec2.payload == packet.payload)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Protocol/BinaryProtocolTests.swift",
    "content": "//\n// BinaryProtocolTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\nstruct BinaryProtocolTests {\n    \n    // MARK: - Basic Encoding/Decoding Tests\n    \n    @Test func basicPacketEncodingDecoding() throws {\n        let originalPacket = TestHelpers.createTestPacket()\n        \n        let encodedData = try #require(BinaryProtocol.encode(originalPacket), \"Failed to encode packet\")\n        let decodedPacket = try #require(BinaryProtocol.decode(encodedData), \"Failed to decode packet\")\n        \n        // Verify\n        #expect(decodedPacket.type == originalPacket.type)\n        #expect(decodedPacket.ttl == originalPacket.ttl)\n        #expect(decodedPacket.timestamp == originalPacket.timestamp)\n        #expect(decodedPacket.payload == originalPacket.payload)\n        \n        // Sender ID should match (accounting for padding)\n        let originalSenderID = originalPacket.senderID.prefix(BinaryProtocol.senderIDSize)\n        let decodedSenderID = decodedPacket.senderID.trimmingNullBytes()\n        #expect(decodedSenderID == originalSenderID)\n    }\n\n    @Test func trimmingNullBytesReturnsOriginalDataWhenNoNullsPresent() {\n        let raw = Data([0x41, 0x42, 0x43])\n        #expect(raw.trimmingNullBytes() == raw)\n    }\n    \n    @Test func packetWithRecipient() throws {\n        let recipientID = PeerID(str: \"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789\")\n        let packet = TestHelpers.createTestPacket(recipientID: recipientID)\n        let encodedData = try #require(BinaryProtocol.encode(packet), \"Failed to encode packet with recipient\")\n        let decodedPacket = try #require(BinaryProtocol.decode(encodedData), \"Failed to decode packet with recipient\")\n        \n        // Verify recipient\n        #expect(decodedPacket.recipientID != nil)\n        let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()\n        // TODO: Check if this is intended that the decoding only gets the first 8\n        #expect(String(data: decodedRecipientID!, encoding: .utf8) == \"abcdef01\")\n    }\n    \n    @Test func packetWithSignature() throws {\n        let packet = TestHelpers.createTestPacket(signature: TestConstants.testSignature)\n        let encodedData = try #require(BinaryProtocol.encode(packet), \"Failed to encode packet with signature\")\n        let decodedPacket = try #require(BinaryProtocol.decode(encodedData), \"Failed to decode packet with signature\")\n        \n        // Verify signature\n        #expect(decodedPacket.signature != nil)\n        #expect(decodedPacket.signature == TestConstants.testSignature)\n    }\n\n    // MARK: - Source-Based Routing Tests (v2 only)\n    \n    @Test func packetWithRouteRoundTrip() throws {\n        let route: [Data] = [\n            try #require(Data(hexString: \"0102030405060708\")),\n            try #require(Data(hexString: \"1112131415161718\")),\n            try #require(Data(hexString: \"2122232425262728\"))\n        ]\n\n        // Route is only supported for v2+ packets\n        var packet = BitchatPacket(\n            type: 0x01,\n            senderID: route[0],\n            recipientID: route.last,\n            timestamp: 1_720_000_000_000,\n            payload: Data(\"route-test\".utf8),\n            signature: nil,\n            ttl: 6,\n            version: 2\n        )\n        packet.route = route\n\n        let encoded = try #require(BinaryProtocol.encode(packet), \"Failed to encode packet with route\")\n        let flagsByte = encoded[BinaryProtocol.Offsets.flags]\n        #expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0)\n\n        let decoded = try #require(BinaryProtocol.decode(encoded), \"Failed to decode packet with route\")\n        #expect(decoded.version == 2)\n        let decodedRoute = try #require(decoded.route)\n        #expect(decodedRoute.count == route.count)\n        for (expected, actual) in zip(route, decodedRoute) {\n            #expect(actual == expected)\n        }\n    }\n\n    @Test func packetWithRoutePadsShortHop() throws {\n        let sender = try #require(Data(hexString: \"0011223344556677\"))\n        let destination = try #require(Data(hexString: \"8899aabbccddeeff\"))\n        let shortHop = Data([0xAA, 0xBB, 0xCC])\n\n        // Route is only supported for v2+ packets\n        var packet = BitchatPacket(\n            type: 0x02,\n            senderID: sender,\n            recipientID: destination,\n            timestamp: 1_730_000_000_000,\n            payload: Data(\"pad-test\".utf8),\n            signature: nil,\n            ttl: 5,\n            version: 2\n        )\n        packet.route = [shortHop, destination]\n\n        let encoded = try #require(BinaryProtocol.encode(packet), \"Failed to encode packet with short hop route\")\n        let decoded = try #require(BinaryProtocol.decode(encoded), \"Failed to decode packet with short hop route\")\n        let decodedRoute = try #require(decoded.route)\n        let firstHop = try #require(decodedRoute.first)\n        #expect(firstHop.count == BinaryProtocol.senderIDSize)\n        #expect(firstHop.prefix(shortHop.count) == shortHop)\n        let paddingBytes = firstHop.suffix(firstHop.count - shortHop.count)\n        #expect(paddingBytes.allSatisfy { $0 == 0 })\n    }\n\n    @Test func packetWithRouteAndCompressedPayload() throws {\n        let route: [Data] = [\n            try #require(Data(hexString: \"0101010101010101\")),\n            try #require(Data(hexString: \"0202020202020202\"))\n        ]\n        let repeatedString = String(repeating: \"compress-me\", count: 150)\n        // Route is only supported for v2+ packets\n        var packet = BitchatPacket(\n            type: 0x03,\n            senderID: route[0],\n            recipientID: route.last,\n            timestamp: 1_740_000_000_000,\n            payload: Data(repeatedString.utf8),\n            signature: nil,\n            ttl: 7,\n            version: 2\n        )\n        packet.route = route\n\n        let encoded = try #require(BinaryProtocol.encode(packet), \"Failed to encode packet with route and compression\")\n        let decoded = try #require(BinaryProtocol.decode(encoded), \"Failed to decode packet with route and compression\")\n        #expect(decoded.payload == Data(repeatedString.utf8))\n        let decodedRoute = try #require(decoded.route)\n        #expect(decodedRoute == route)\n    }\n    \n    @Test func v1PacketIgnoresRouteOnEncode() throws {\n        // v1 packets should NOT include route even if route is set on the packet object\n        let route: [Data] = [\n            try #require(Data(hexString: \"0102030405060708\")),\n            try #require(Data(hexString: \"1112131415161718\"))\n        ]\n        \n        var packet = BitchatPacket(\n            type: 0x01,\n            senderID: route[0],\n            recipientID: route.last,\n            timestamp: 1_720_000_000_000,\n            payload: Data(\"v1-no-route\".utf8),\n            signature: nil,\n            ttl: 6\n            // version defaults to 1 (v1 packet)\n        )\n        packet.route = route  // route is set but should be ignored for v1\n        \n        let encoded = try #require(BinaryProtocol.encode(packet), \"Failed to encode v1 packet\")\n        \n        // HAS_ROUTE flag should NOT be set for v1 packets\n        let flagsByte = encoded[BinaryProtocol.Offsets.flags]\n        #expect((flagsByte & BinaryProtocol.Flags.hasRoute) == 0, \"v1 packet should not have HAS_ROUTE flag set\")\n        \n        // Decoded packet should have no route\n        let decoded = try #require(BinaryProtocol.decode(encoded), \"Failed to decode v1 packet\")\n        #expect(decoded.version == 1)\n        #expect(decoded.route == nil, \"v1 packet should decode with nil route\")\n        #expect(decoded.payload == Data(\"v1-no-route\".utf8))\n    }\n    \n    @Test func v2PacketIncludesRouteOnEncode() throws {\n        // v2 packets SHOULD include route when route is set\n        let route: [Data] = [\n            try #require(Data(hexString: \"0102030405060708\")),\n            try #require(Data(hexString: \"1112131415161718\"))\n        ]\n        \n        var packet = BitchatPacket(\n            type: 0x01,\n            senderID: route[0],\n            recipientID: route.last,\n            timestamp: 1_720_000_000_000,\n            payload: Data(\"v2-with-route\".utf8),\n            signature: nil,\n            ttl: 6,\n            version: 2\n        )\n        packet.route = route\n        \n        let encoded = try #require(BinaryProtocol.encode(packet), \"Failed to encode v2 packet\")\n        \n        // HAS_ROUTE flag SHOULD be set for v2 packets with route\n        let flagsByte = encoded[BinaryProtocol.Offsets.flags]\n        #expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0, \"v2 packet should have HAS_ROUTE flag set\")\n        \n        // Decoded packet should have route\n        let decoded = try #require(BinaryProtocol.decode(encoded), \"Failed to decode v2 packet\")\n        #expect(decoded.version == 2)\n        let decodedRoute = try #require(decoded.route, \"v2 packet should decode with route\")\n        #expect(decodedRoute.count == route.count)\n        #expect(decoded.payload == Data(\"v2-with-route\".utf8))\n    }\n    \n    @Test func v2PacketWithoutRouteDecodesCorrectly() throws {\n        // v2 packet without route should still work\n        let sender = try #require(Data(hexString: \"0011223344556677\"))\n        let recipient = try #require(Data(hexString: \"8899aabbccddeeff\"))\n        \n        let packet = BitchatPacket(\n            type: 0x02,\n            senderID: sender,\n            recipientID: recipient,\n            timestamp: 1_750_000_000_000,\n            payload: Data(\"v2-no-route\".utf8),\n            signature: nil,\n            ttl: 5,\n            version: 2\n        )\n        // route is nil by default\n        \n        let encoded = try #require(BinaryProtocol.encode(packet), \"Failed to encode v2 packet without route\")\n        \n        // HAS_ROUTE flag should NOT be set when no route\n        let flagsByte = encoded[BinaryProtocol.Offsets.flags]\n        #expect((flagsByte & BinaryProtocol.Flags.hasRoute) == 0, \"v2 packet without route should not have HAS_ROUTE flag\")\n        \n        let decoded = try #require(BinaryProtocol.decode(encoded), \"Failed to decode v2 packet without route\")\n        #expect(decoded.version == 2)\n        #expect(decoded.route == nil)\n        #expect(decoded.payload == Data(\"v2-no-route\".utf8))\n    }\n    \n    @Test func v1AndV2PayloadLengthDifference() throws {\n        // Verify that payloadLength does NOT include route bytes\n        // by comparing encoded sizes\n        let route: [Data] = [\n            try #require(Data(hexString: \"0102030405060708\"))\n        ]\n        let payloadData = Data(\"test-payload\".utf8)\n        \n        // v1 packet (route ignored)\n        var v1Packet = BitchatPacket(\n            type: 0x01,\n            senderID: route[0],\n            recipientID: nil,\n            timestamp: 1_720_000_000_000,\n            payload: payloadData,\n            signature: nil,\n            ttl: 6\n            // version defaults to 1\n        )\n        v1Packet.route = route  // will be ignored for v1\n        \n        // v2 packet with same payload but route included\n        var v2Packet = BitchatPacket(\n            type: 0x01,\n            senderID: route[0],\n            recipientID: nil,\n            timestamp: 1_720_000_000_000,\n            payload: payloadData,\n            signature: nil,\n            ttl: 6,\n            version: 2\n        )\n        v2Packet.route = route\n        \n        let v1Encoded = try #require(BinaryProtocol.encode(v1Packet, padding: false))\n        let v2Encoded = try #require(BinaryProtocol.encode(v2Packet, padding: false))\n        \n        // v2 should be larger by: 2 bytes (header length field difference) + 1 byte (route count) + 8 bytes (one hop)\n        // Header: v1=14, v2=16 -> +2 bytes\n        // Route: 1 + 8 = 9 bytes\n        // Total expected difference: 11 bytes\n        let expectedDiff = 2 + 1 + 8  // header diff + route count + one hop\n        #expect(v2Encoded.count - v1Encoded.count == expectedDiff, \n                \"v2 packet should be \\(expectedDiff) bytes larger than v1 (actual diff: \\(v2Encoded.count - v1Encoded.count))\")\n    }\n    \n    // MARK: - Compression Tests\n    \n    @Test(\"Create a large, compressible payload above current threshold (2048B)\")\n    func payloadCompression() throws {\n        let repeatedString = String(repeating: \"This is a test message. \", count: 200)\n        let largePayload = repeatedString.data(using: .utf8)!\n        \n        let packet = TestHelpers.createTestPacket(payload: largePayload)\n        \n        // Encode (should compress)\n        let encodedData = try #require(BinaryProtocol.encode(packet), \"Failed to encode packet with large payload\")\n        \n        // The encoded size should be smaller than uncompressed due to compression\n        let headerSize = try #require(BinaryProtocol.headerSize(for: packet.version), \"Invalid packet version\")\n        let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count\n        #expect(encodedData.count < uncompressedSize, \"Compressed packet should be smaller than uncompressed form\")\n        \n        // Decode and verify\n        let decodedPacket = try #require(BinaryProtocol.decode(encodedData), \"Failed to decode compressed packet\")\n        \n        #expect(decodedPacket.payload == largePayload)\n    }\n    \n    @Test(\"Small payloads should not be compressed\")\n    func smallPayloadNoCompression() throws {\n        let smallPayload = \"Hi\".data(using: .utf8)!\n        let packet = TestHelpers.createTestPacket(payload: smallPayload)\n        let encodedData = try #require(BinaryProtocol.encode(packet), \"Failed to encode small packet\")\n        let decodedPacket = try #require(BinaryProtocol.decode(encodedData), \"Failed to decode small packet\")\n        #expect(decodedPacket.payload == smallPayload)\n    }\n\n    @Test(\"Reject payloads larger than the framed file cap\")\n    func oversizedPayloadIsRejected() throws {\n        let targetSize = FileTransferLimits.maxFramedFileBytes + 1\n        var oversized = Data()\n        oversized.reserveCapacity(targetSize)\n        let byteRun = Data((0...255).map { UInt8($0) })\n        while oversized.count < targetSize {\n            let remaining = targetSize - oversized.count\n            if remaining >= byteRun.count {\n                oversized.append(byteRun)\n            } else {\n                oversized.append(byteRun.prefix(remaining))\n            }\n        }\n        let packet = BitchatPacket(\n            type: MessageType.message.rawValue,\n            senderID: Data(hexString: \"0011223344556677\") ?? Data(),\n            recipientID: nil,\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: oversized,\n            signature: nil,\n            ttl: 1,\n            version: 2\n        )\n        let encoded = try #require(BinaryProtocol.encode(packet), \"Failed to encode oversized packet\")\n        #expect(BinaryProtocol.decode(encoded) == nil)\n    }\n    \n    // MARK: - Message Padding Tests\n    \n    @Test func messagePadding() throws {\n        let payloads = [\n            \"Short\",\n            String(repeating: \"Medium length message content \", count: 10), // ~300 bytes  \n            String(repeating: \"Long message content that should exceed the 512 byte limit \", count: 20), // ~1200+ bytes\n            String(repeating: \"Very long message content that should definitely exceed the 2048 byte limit for sure \", count: 30) // ~2700+ bytes\n        ]\n        \n        var encodedSizes = Set<Int>()\n        \n        for payload in payloads {\n            let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)\n            let encodedData = try #require(BinaryProtocol.encode(packet), \"Failed to encode packet\")\n            \n            // Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)\n            let blockSizes = [256, 512, 1024, 2048]\n            if encodedData.count <= 2048 {\n                #expect(blockSizes.contains(encodedData.count), \"Encoded size \\(encodedData.count) is not a standard block size\")\n            } else {\n                // For very large payloads we expect no additional padding beyond raw size\n                #expect(encodedData.count > 2048)\n            }\n            \n            encodedSizes.insert(encodedData.count)\n            \n            // Verify decoding works\n            let decodedPacket = try #require(BinaryProtocol.decode(encodedData), \"Failed to decode padded packet\")\n            #expect(String(data: decodedPacket.payload, encoding: .utf8) == payload)\n        }\n        \n        // Different payload sizes (within <=2048) may map to the same bucket depending on compression.\n        // Require at least one padded size to be present.\n        #expect(encodedSizes.filter { $0 <= 2048 }.count >= 1, \"Expected at least one padded size up to 2048, got \\(encodedSizes)\")\n    }\n\n    @Test func invalidPKCS7PaddingIsRejected() throws {\n        let pkt = TestHelpers.createTestPacket(payload: Data(repeating: 0x41, count: 50)) // small\n        let enc0 = try #require(BinaryProtocol.encode(pkt), \"encode failed\")\n        // Force padding to known block for test stability\n        var enc = MessagePadding.pad(enc0, toSize: 256)\n        let unpadded = MessagePadding.unpad(enc)\n        let padLen = enc.count - unpadded.count\n        if padLen > 0 {\n            // Set last pad byte to wrong value (padLen-1) to break PKCS#7\n            enc[enc.count - 1] = UInt8((padLen - 1) & 0xFF)\n            let maybe = BinaryProtocol.decode(enc)\n            // If decode still succeeds (nested pad edge case), at least ensure payload integrity\n            if let pkt2 = maybe {\n                #expect(pkt2.payload == pkt.payload)\n            } else {\n                #expect(maybe == nil)\n            }\n        } else {\n            // If no padding was applied, just assert decode succeeds (nothing to test)\n            #expect(BinaryProtocol.decode(enc) != nil)\n        }\n    }\n    \n    // MARK: - Message Encoding/Decoding Tests\n    \n    @Test func messageEncodingDecoding() throws {\n        let message = TestHelpers.createTestMessage()\n        \n        let payload = try #require(message.toBinaryPayload(), \"Failed to encode message to binary\")\n        \n        let decodedMessage = try #require(BitchatMessage(payload), \"Failed to decode message from binary\")\n        \n        #expect(decodedMessage.content == message.content)\n        #expect(decodedMessage.sender == message.sender)\n        #expect(decodedMessage.senderPeerID == message.senderPeerID)\n        #expect(decodedMessage.isPrivate == message.isPrivate)\n        \n        // Timestamp should be close (within 1 second due to conversion)\n        let timeDiff = abs(decodedMessage.timestamp.timeIntervalSince(message.timestamp))\n        #expect(timeDiff < 1)\n    }\n    \n    func testPrivateMessageEncoding() throws {\n        let message = TestHelpers.createTestMessage(\n            isPrivate: true,\n            recipientNickname: TestConstants.testNickname2\n        )\n        \n        let payload = try #require(message.toBinaryPayload(), \"Failed to encode private message\")\n        let decodedMessage = try #require(BitchatMessage(payload), \"Failed to decode private message\")\n        \n        #expect(decodedMessage.isPrivate)\n        #expect(decodedMessage.recipientNickname == TestConstants.testNickname2)\n    }\n    \n    @Test func messageWithMentions() throws {\n        let mentions = [TestConstants.testNickname2, TestConstants.testNickname3]\n        let message = TestHelpers.createTestMessage(mentions: mentions)\n        let payload = try #require(message.toBinaryPayload(), \"Failed to encode message with mentions\")\n        let decodedMessage = try #require(BitchatMessage(payload), \"Failed to decode message with mentions\")\n        #expect(decodedMessage.mentions == mentions)\n    }\n    \n    @Test func relayMessageEncoding() throws {\n        let message = BitchatMessage(\n            id: UUID().uuidString,\n            sender: TestConstants.testNickname1,\n            content: TestConstants.testMessage1,\n            timestamp: Date(),\n            isRelay: true,\n            originalSender: TestConstants.testNickname3,\n            isPrivate: false,\n            recipientNickname: nil,\n            mentions: nil\n        )\n        let payload = try #require(message.toBinaryPayload(), \"Failed to encode relay message\")\n        let decodedMessage = try #require(BitchatMessage(payload), \"Failed to decode relay message\")\n        #expect(decodedMessage.isRelay)\n        #expect(decodedMessage.originalSender == TestConstants.testNickname3)\n    }\n    \n    // MARK: - Edge Cases and Error Handling\n    \n    @Test(\"Too small data\")\n    func invalidDataDecoding() throws {\n        let tooSmall = Data(repeating: 0, count: 5)\n        #expect(BinaryProtocol.decode(tooSmall) == nil)\n        \n        // Random data\n        let random = TestHelpers.generateRandomData(length: 100)\n        #expect(BinaryProtocol.decode(random) == nil)\n        \n        // Corrupted header\n        let packet = TestHelpers.createTestPacket()\n        var encoded = try #require(BinaryProtocol.encode(packet), \"Failed to encode test packet\")\n        \n        // Corrupt the version byte\n        encoded[0] = 0xFF\n        #expect(BinaryProtocol.decode(encoded) == nil)\n    }\n    \n    @Test(\"Test maximum size handling\")\n    func largeMessageHandling() throws {\n        let largeContent = String(repeating: \"X\", count: 65535) // Max uint16\n        let message = TestHelpers.createTestMessage(content: largeContent)\n        let payload = try #require(message.toBinaryPayload(), \"Failed to handle large message\")\n        let decodedMessage = try #require(BitchatMessage(payload), \"Failed to handle large message\")\n        #expect(decodedMessage.content == largeContent)\n    }\n    \n    @Test(\"Test message with empty content\")\n    func emptyFieldsHandling() throws {\n        let emptyMessage = TestHelpers.createTestMessage(content: \"\")\n        let payload = try #require(emptyMessage.toBinaryPayload(), \"Failed to handle empty message\")\n        let decodedMessage = try #require(BitchatMessage(payload), \"Failed to handle empty message\")\n        #expect(decodedMessage.content.isEmpty)\n    }\n    \n    // MARK: - Protocol Version Tests\n    \n    @Test(\"Test with supported version (version is always 1 in init)\")\n    func protocolVersionHandling() throws {\n        let packet = TestHelpers.createTestPacket()\n        let encoded = try #require(BinaryProtocol.encode(packet), \"Failed to encode packet with version\")\n        let decoded = try #require(BinaryProtocol.decode(encoded), \"Failed to decode packet with version\")\n        #expect(decoded.version == 1)\n    }\n    \n    @Test(\"Create packet data with unsupported version\")\n    func unsupportedProtocolVersion() throws {\n        let packet = TestHelpers.createTestPacket()\n        var encoded = try #require(BinaryProtocol.encode(packet), \"Failed to encode packet\")\n        \n        // Manually change version byte to unsupported value\n        encoded[0] = 99 // Unsupported version\n        \n        // Should fail to decode\n        #expect(BinaryProtocol.decode(encoded) == nil)\n    }\n    \n    // MARK: - Bounds Checking Tests (Crash Prevention)\n    \n    @Test(\"Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available\")\n    func malformedPacketWithInvalidPayloadLength() throws {\n        var malformedData = Data()\n        \n        // Valid header (13 bytes)\n        malformedData.append(1) // version\n        malformedData.append(1) // type  \n        malformedData.append(10) // ttl\n        \n        // Timestamp (8 bytes)\n        for _ in 0..<8 {\n            malformedData.append(0)\n        }\n        \n        malformedData.append(0) // flags (no recipient, no signature, not compressed)\n        \n        // Invalid payload length: 193 (0x00c1) but we'll only provide 8 bytes total data\n        malformedData.append(0x00) // high byte\n        malformedData.append(0xc1) // low byte (193)\n        \n        // SenderID (8 bytes) - this brings us to 21 bytes total\n        for _ in 0..<8 {\n            malformedData.append(0x01)\n        }\n        \n        // Only provide 8 more bytes instead of the claimed 193\n        for _ in 0..<8 {\n            malformedData.append(0x02)\n        }\n        \n        // Total data is now 30 bytes, but payloadLength claims 193\n        #expect(malformedData.count == 30)\n        \n        // This should not crash - should return nil gracefully\n        let result = BinaryProtocol.decode(malformedData)\n        #expect(result == nil, \"Malformed packet with invalid payload length should return nil, not crash\")\n    }\n    \n    @Test(\"Test various truncation scenarios\")\n    func truncatedPacketHandling() throws {\n        let packet = TestHelpers.createTestPacket()\n        let validEncoded = try #require(BinaryProtocol.encode(packet), \"Failed to encode test packet\")\n        \n        // Test truncation at various points\n        let truncationPoints = [0, 5, 10, 15, 20, 25]\n        \n        for point in truncationPoints {\n            let truncated = validEncoded.prefix(point)\n            let result = BinaryProtocol.decode(truncated)\n            #expect(result == nil, \"Truncated packet at \\(point) bytes should return nil, not crash\")\n        }\n    }\n    \n    @Test(\"Test compressed packet with invalid original size\")\n    func malformedCompressedPacket() throws {\n        var malformedData = Data()\n        \n        // Valid header\n        malformedData.append(1) // version\n        malformedData.append(1) // type\n        malformedData.append(10) // ttl\n        \n        // Timestamp (8 bytes)\n        for _ in 0..<8 {\n            malformedData.append(0)\n        }\n        \n        malformedData.append(0x04) // flags: isCompressed = true\n        \n        // Small payload length that's insufficient for compression\n        malformedData.append(0x00) // high byte  \n        malformedData.append(0x01) // low byte (1 byte - insufficient for 2-byte original size)\n        \n        // SenderID (8 bytes)\n        for _ in 0..<8 {\n            malformedData.append(0x01)\n        }\n        \n        // Only 1 byte of \"compressed\" data (should need at least 2 for original size)\n        malformedData.append(0x99)\n        \n        // Should handle this gracefully\n        let result = BinaryProtocol.decode(malformedData)\n        #expect(result == nil, \"Malformed compressed packet should return nil, not crash\")\n    }\n    \n    @Test(\"Test packet claiming extremely large payload\")\n    func excessivelyLargePayloadLength() throws {\n        var malformedData = Data()\n        \n        // Valid header\n        malformedData.append(1) // version\n        malformedData.append(1) // type\n        malformedData.append(10) // ttl\n        \n        // Timestamp (8 bytes)\n        for _ in 0..<8 {\n            malformedData.append(0)\n        }\n        \n        malformedData.append(0) // flags\n        \n        // Maximum payload length (65535)\n        malformedData.append(0xFF) // high byte\n        malformedData.append(0xFF) // low byte\n        \n        // SenderID (8 bytes)\n        for _ in 0..<8 {\n            malformedData.append(0x01)\n        }\n        \n        // Provide only a tiny amount of actual data\n        malformedData.append(contentsOf: [0x01, 0x02, 0x03])\n        \n        // Should handle this gracefully without trying to allocate massive amounts of memory\n        let result = BinaryProtocol.decode(malformedData)\n        #expect(result == nil, \"Packet with excessive payload length should return nil, not crash\")\n    }\n    \n    @Test(\"Test compressed packet with unreasonable original size\")\n    func compressedPacketWithInvalidOriginalSize() throws {\n        var malformedData = Data()\n        \n        // Valid header\n        malformedData.append(1) // version\n        malformedData.append(1) // type\n        malformedData.append(10) // ttl\n        \n        // Timestamp (8 bytes)\n        for _ in 0..<8 {\n            malformedData.append(0)\n        }\n        \n        malformedData.append(0x04) // flags: isCompressed = true\n        \n        // Reasonable payload length\n        malformedData.append(0x00) // high byte\n        malformedData.append(0x10) // low byte (16 bytes)\n        \n        // SenderID (8 bytes)\n        for _ in 0..<8 {\n            malformedData.append(0x01)\n        }\n        \n        // Original size claiming to be extremely large (2MB)\n        malformedData.append(0x20) // high byte of original size\n        malformedData.append(0x00) // low byte of original size (0x2000 = 8192, but let's make it larger with more bytes)\n        \n        // Add more bytes to make it claim larger size - but this will be invalid\n        // because our validation should catch unreasonable sizes\n        malformedData.append(contentsOf: [0x01, 0x02, 0x03, 0x04]) // Some compressed data\n        \n        // Pad to match payload length\n        while malformedData.count < 21 + 16 { // header + senderID + payload\n            malformedData.append(0x00)\n        }\n        \n        let result = BinaryProtocol.decode(malformedData)\n        #expect(result == nil, \"Compressed packet with invalid original size should return nil, not crash\")\n    }\n\n    @Test(\"Test compressed packet with suspicious compression ratio\")\n    func compressedPacketWithSuspiciousCompressionRatio() {\n        var malformedData = Data()\n\n        malformedData.append(1)     // version\n        malformedData.append(1)     // type\n        malformedData.append(10)    // ttl\n\n        for _ in 0..<8 {\n            malformedData.append(0)\n        }\n\n        malformedData.append(0x04)  // isCompressed\n        malformedData.append(0x00)\n        malformedData.append(0x03)  // payloadLength = 3 (2 original-size bytes + 1 compressed byte)\n\n        for _ in 0..<8 {\n            malformedData.append(0x01)\n        }\n\n        malformedData.append(0xFF)\n        malformedData.append(0xFF)  // originalSize = 65535\n        malformedData.append(0x99)  // compressed payload length = 1 => ratio > 50_000\n\n        #expect(BinaryProtocol.decode(malformedData) == nil)\n    }\n    \n    @Test(\"Test packet designed to cause integer overflow\")\n    func maliciousPacketWithIntegerOverflow() throws {\n        var maliciousData = Data()\n        \n        // Valid header\n        maliciousData.append(1) // version\n        maliciousData.append(1) // type\n        maliciousData.append(10) // ttl\n        \n        // Timestamp (8 bytes)\n        for _ in 0..<8 {\n            maliciousData.append(0)\n        }\n        \n        // Set flags to have recipient and signature (increase expected size)\n        maliciousData.append(0x03) // hasRecipient | hasSignature\n        \n        // Very large payload length\n        maliciousData.append(0xFF) // high byte\n        maliciousData.append(0xFE) // low byte (65534)\n        \n        // SenderID (8 bytes)\n        for _ in 0..<8 {\n            maliciousData.append(0x01)\n        }\n        \n        // RecipientID (8 bytes - required due to flag)\n        for _ in 0..<8 {\n            maliciousData.append(0x02)\n        }\n        \n        // Provide minimal payload data - should trigger bounds check failure\n        maliciousData.append(contentsOf: [0x01, 0x02])\n        \n        // Should handle gracefully without integer overflow issues\n        let result = BinaryProtocol.decode(maliciousData)\n        #expect(result == nil, \"Malicious packet designed for integer overflow should return nil, not crash\")\n    }\n    \n    @Test(\"Test packets with incomplete headers\")\n    func partialHeaderData() throws {\n        let headerSizes = [0, 1, 5, 10, 12] // Various incomplete header sizes\n        \n        for size in headerSizes {\n            let partialData = Data(repeating: 0x01, count: size)\n            let result = BinaryProtocol.decode(partialData)\n            #expect(result == nil, \"Partial header data (\\(size) bytes) should return nil, not crash\")\n        }\n    }\n    \n    @Test(\"Test exact boundary conditions\")\n    func boundaryConditions() throws {\n        let packet = TestHelpers.createTestPacket()\n        let validEncoded = try #require(BinaryProtocol.encode(packet), \"Failed to encode test packet\")\n        \n        // If truncation only removes padding, decode may still succeed. Compute unpadded size.\n        let unpadded = MessagePadding.unpad(validEncoded)\n        // Truncate within the unpadded frame to guarantee corruption\n        let cut = max(1, unpadded.count - 10)\n        let truncatedCore = unpadded.prefix(cut)\n        let result = BinaryProtocol.decode(truncatedCore)\n        #expect(result == nil, \"Truncated core frame should return nil, not crash\")\n        \n        // Test minimum valid size - create a valid minimal packet\n        var minData = Data()\n        minData.append(1) // version\n        minData.append(1) // type\n        minData.append(10) // ttl\n        \n        // Timestamp (8 bytes)\n        for _ in 0..<8 {\n            minData.append(0)\n        }\n        \n        minData.append(0) // flags (no optional fields)\n        minData.append(0) // payload length high byte\n        minData.append(0) // payload length low byte (0 payload)\n        \n        // SenderID (8 bytes)\n        for _ in 0..<8 {\n            minData.append(0x01)\n        }\n        \n        // This should be exactly the minimum size and should decode without crashing\n        _ = BinaryProtocol.decode(minData)\n        // The important thing is no crash occurs - result might be nil or valid\n        // We don't assert the result, just that no crash happens\n    }\n}\n"
  },
  {
    "path": "bitchatTests/ProtocolContractTests.swift",
    "content": "import Testing\nimport Foundation\nimport Combine\nimport CoreBluetooth\n@testable import bitchat\n\nprivate final class DefaultDelegateProbe: BitchatDelegate {\n    func didReceiveMessage(_ message: BitchatMessage) {}\n    func didConnectToPeer(_ peerID: PeerID) {}\n    func didDisconnectFromPeer(_ peerID: PeerID) {}\n    func didUpdatePeerList(_ peers: [PeerID]) {}\n    func didUpdateBluetoothState(_ state: CBManagerState) {}\n}\n\nprivate final class DefaultTransportProbe: Transport {\n    weak var delegate: BitchatDelegate?\n    weak var peerEventsDelegate: TransportPeerEventsDelegate?\n\n    let subject = CurrentValueSubject<[TransportPeerSnapshot], Never>([])\n    let myPeerID = PeerID(str: \"0011223344556677\")\n    var myNickname = \"Tester\"\n    private let keychain = MockKeychain()\n    private(set) var sentMessages: [(content: String, mentions: [String])] = []\n\n    var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {\n        subject.eraseToAnyPublisher()\n    }\n\n    func currentPeerSnapshots() -> [TransportPeerSnapshot] { subject.value }\n    func setNickname(_ nickname: String) { myNickname = nickname }\n    func startServices() {}\n    func stopServices() {}\n    func emergencyDisconnectAll() {}\n    func isPeerConnected(_ peerID: PeerID) -> Bool { false }\n    func isPeerReachable(_ peerID: PeerID) -> Bool { false }\n    func peerNickname(peerID: PeerID) -> String? { nil }\n    func getPeerNicknames() -> [PeerID: String] { [:] }\n    func getFingerprint(for peerID: PeerID) -> String? { nil }\n    func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }\n    func triggerHandshake(with peerID: PeerID) {}\n    func getNoiseService() -> NoiseEncryptionService { NoiseEncryptionService(keychain: keychain) }\n    func sendMessage(_ content: String, mentions: [String]) { sentMessages.append((content, mentions)) }\n    func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {}\n    func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {}\n    func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}\n    func sendBroadcastAnnounce() {}\n    func sendDeliveryAck(for messageID: String, to peerID: PeerID) {}\n}\n\nstruct ProtocolContractTests {\n    @Test\n    func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {\n        #expect(CommandInfo.message.id == \"dm\")\n        #expect(CommandInfo.message.alias == \"/dm\")\n        #expect(CommandInfo.message.placeholder != nil)\n        #expect(CommandInfo.clear.placeholder == nil)\n        #expect(CommandInfo.favorite.description.isEmpty == false)\n        #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false)\n        #expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite))\n        #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite))\n    }\n\n    @Test\n    func protocolEnums_andDelegateDefaults_haveStableContracts() {\n        let delegate = DefaultDelegateProbe()\n        let peerID = PeerID(str: \"8899aabbccddeeff\")\n\n        #expect(MessageType.requestSync.description == \"requestSync\")\n        #expect(NoisePayloadType.verifyResponse.description == \"verifyResponse\")\n        #expect(DeliveryStatus.sending.displayText == \"Sending...\")\n        #expect(DeliveryStatus.sent.displayText == \"Sent\")\n        #expect(DeliveryStatus.delivered(to: \"Alice\", at: Date()).displayText == \"Delivered to Alice\")\n        #expect(DeliveryStatus.read(by: \"Bob\", at: Date()).displayText == \"Read by Bob\")\n        #expect(DeliveryStatus.failed(reason: \"oops\").displayText == \"Failed: oops\")\n        #expect(DeliveryStatus.partiallyDelivered(reached: 1, total: 3).displayText == \"Delivered to 1/3\")\n        #expect(delegate.isFavorite(fingerprint: \"fp\") == false)\n\n        delegate.didUpdateMessageDeliveryStatus(\"msg-1\", status: .sent)\n        delegate.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(), timestamp: Date())\n        delegate.didReceivePublicMessage(from: peerID, nickname: \"Alice\", content: \"hi\", timestamp: Date(), messageID: \"msg-1\")\n    }\n\n    @Test\n    func transportDefaults_forwardOrNoOp() {\n        let probe = DefaultTransportProbe()\n        let peerID = PeerID(str: \"0123456789abcdef\")\n        let filePacket = BitchatFilePacket(\n            fileName: \"voice.m4a\",\n            fileSize: 4,\n            mimeType: \"audio/mp4\",\n            content: Data([1, 2, 3, 4])\n        )\n\n        probe.sendMessage(\"hello\", mentions: [\"@alice\"], messageID: \"msg-1\", timestamp: Date())\n        probe.sendVerifyChallenge(to: peerID, noiseKeyHex: \"abcd\", nonceA: Data([0x01]))\n        probe.sendVerifyResponse(to: peerID, noiseKeyHex: \"abcd\", nonceA: Data([0x02]))\n        probe.sendFileBroadcast(filePacket, transferId: \"tx-1\")\n        probe.sendFilePrivate(filePacket, to: peerID, transferId: \"tx-2\")\n        probe.cancelTransfer(\"tx-3\")\n        probe.declinePendingFile(id: \"pending\")\n\n        #expect(probe.sentMessages.count == 1)\n        #expect(probe.sentMessages.first?.content == \"hello\")\n        #expect(probe.acceptPendingFile(id: \"pending\") == nil)\n    }\n\n    @Test\n    func previewMessage_exposesStableSampleShape() {\n        let preview = BitchatMessage.preview\n\n        #expect(preview.sender == \"John Doe\")\n        #expect(preview.content == \"Hello\")\n        #expect(preview.deliveryStatus == .sent)\n        #expect(preview.isPrivate == false)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Protocols/BinaryEncodingUtilsTests.swift",
    "content": "import Foundation\nimport XCTest\n@testable import bitchat\n\nfinal class BinaryEncodingUtilsTests: XCTestCase {\n    func test_appendAndReadPrimitiveValues_roundTrip() throws {\n        var data = Data()\n        data.appendUInt8(0x12)\n        data.appendUInt16(0x3456)\n        data.appendUInt32(0x789ABCDE)\n        data.appendUInt64(0x0123456789ABCDEF)\n\n        var offset = 0\n        XCTAssertEqual(data.readUInt8(at: &offset), 0x12)\n        XCTAssertEqual(data.readUInt16(at: &offset), 0x3456)\n        XCTAssertEqual(data.readUInt32(at: &offset), 0x789ABCDE)\n        XCTAssertEqual(data.readUInt64(at: &offset), 0x0123456789ABCDEF)\n        XCTAssertEqual(offset, data.count)\n    }\n\n    func test_appendAndReadStringDataAndDate_roundTrip() throws {\n        let expectedDate = Date(timeIntervalSince1970: 1_700_000_000.123)\n        let expectedPayload = Data([0xAA, 0xBB, 0xCC, 0xDD])\n        var data = Data()\n\n        data.appendString(\"hello\")\n        data.appendData(expectedPayload)\n        data.appendDate(expectedDate)\n\n        var offset = 0\n        XCTAssertEqual(data.readString(at: &offset), \"hello\")\n        XCTAssertEqual(data.readData(at: &offset), expectedPayload)\n        let decodedDate = try XCTUnwrap(data.readDate(at: &offset))\n        XCTAssertEqual(decodedDate.timeIntervalSince1970, expectedDate.timeIntervalSince1970, accuracy: 0.001)\n    }\n\n    func test_appendUUID_and_readUUID_roundTrip() throws {\n        let uuid = \"12345678-90ab-cdef-1234-567890abcdef\"\n        var data = Data()\n\n        data.appendUUID(uuid)\n\n        var offset = 0\n        XCTAssertEqual(data.readUUID(at: &offset), uuid.uppercased())\n    }\n\n    func test_appendStringAndData_truncateToConfiguredMaxLength() throws {\n        var data = Data()\n        data.appendString(\"abcdef\", maxLength: 4)\n        data.appendData(Data([1, 2, 3, 4, 5]), maxLength: 3)\n\n        var offset = 0\n        XCTAssertEqual(data.readString(at: &offset), \"abcd\")\n        XCTAssertEqual(data.readData(at: &offset, maxLength: 3), Data([1, 2, 3]))\n    }\n\n    func test_readMethods_returnNilWhenOutOfBounds() {\n        var offset = 0\n        let shortData = Data([0x01])\n\n        XCTAssertNil(shortData.readUInt16(at: &offset))\n        XCTAssertEqual(offset, 0)\n\n        offset = 0\n        XCTAssertNil(shortData.readString(at: &offset))\n        XCTAssertEqual(offset, 1)\n\n        offset = 0\n        XCTAssertNil(shortData.readFixedBytes(at: &offset, count: 2))\n        XCTAssertEqual(offset, 0)\n    }\n\n    func test_sha256Hex_andExtendedLengthStringRoundTrip() throws {\n        XCTAssertEqual(\n            Data(\"abc\".utf8).sha256Hex(),\n            \"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad\"\n        )\n\n        var data = Data()\n        data.appendString(\"hello\", maxLength: 300)\n\n        var offset = 0\n        XCTAssertEqual(data.readString(at: &offset, maxLength: 300), \"hello\")\n    }\n\n    func test_readString_returnsNilForInvalidUTF8ExtendedPayload() {\n        let invalidUTF8 = Data([0x00, 0x02, 0xFF, 0xFF])\n        var offset = 0\n\n        XCTAssertNil(invalidUTF8.readString(at: &offset, maxLength: 300))\n        XCTAssertEqual(offset, invalidUTF8.count)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Protocols/BitchatFilePacketTests.swift",
    "content": "import XCTest\n@testable import bitchat\n\nfinal class BitchatFilePacketTests: XCTestCase {\n\n    func testRoundTripPreservesFields() throws {\n        let content = Data((0..<4096).map { UInt8($0 % 251) })\n        let packet = BitchatFilePacket(\n            fileName: \"sample.jpg\",\n            fileSize: UInt64(content.count),\n            mimeType: \"image/jpeg\",\n            content: content\n        )\n\n        guard let encoded = packet.encode() else {\n            return XCTFail(\"Failed to encode file packet\")\n        }\n        guard let decoded = BitchatFilePacket.decode(encoded) else {\n            return XCTFail(\"Failed to decode file packet\")\n        }\n\n        XCTAssertEqual(decoded.fileName, packet.fileName)\n        XCTAssertEqual(decoded.fileSize, packet.fileSize)\n        XCTAssertEqual(decoded.mimeType, packet.mimeType)\n        XCTAssertEqual(decoded.content, packet.content)\n    }\n\n    func testDecodeFallsBackToContentSizeWhenFileSizeMissing() throws {\n        let content = Data(repeating: 0x7F, count: 1024)\n        let packet = BitchatFilePacket(\n            fileName: nil,\n            fileSize: nil,\n            mimeType: nil,\n            content: content\n        )\n\n        guard let encoded = packet.encode() else {\n            return XCTFail(\"Failed to encode file packet\")\n        }\n        guard let decoded = BitchatFilePacket.decode(encoded) else {\n            return XCTFail(\"Failed to decode file packet\")\n        }\n\n        XCTAssertEqual(decoded.fileSize, UInt64(content.count))\n        XCTAssertEqual(decoded.content, content)\n    }\n\n    func testDecodeSupportsLegacyEightByteFileSizeTLV() throws {\n        let content = Data([0x01, 0x02, 0x03, 0x04])\n        var data = Data()\n\n        data.append(0x02)\n        data.append(contentsOf: [0x00, 0x08])\n        data.append(contentsOf: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00])\n        data.append(0x04)\n        data.append(contentsOf: [0x00, 0x00, 0x00, 0x04])\n        data.append(content)\n\n        let decoded = try XCTUnwrap(BitchatFilePacket.decode(data))\n        XCTAssertEqual(decoded.fileSize, 256)\n        XCTAssertEqual(decoded.content, content)\n    }\n\n    func testDecodeUsesContentCountWhenFileSizeTLVIsMissing() throws {\n        let content = Data([0xAA, 0xBB, 0xCC])\n        var data = Data()\n\n        data.append(0x04)\n        data.append(contentsOf: [0x00, 0x00, 0x00, 0x03])\n        data.append(content)\n\n        let decoded = try XCTUnwrap(BitchatFilePacket.decode(data))\n        XCTAssertEqual(decoded.fileSize, UInt64(content.count))\n        XCTAssertEqual(decoded.content, content)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Protocols/LocationChannelTests.swift",
    "content": "import Foundation\nimport Testing\n\n@testable import bitchat\n\nstruct LocationChannelTests {\n    @Test\n    func geohashChannelLevelDisplayNamesAndLegacyDecoding() throws {\n        for level in GeohashChannelLevel.allCases {\n            #expect(level.displayName.isEmpty == false)\n        }\n\n        #expect(try decodeLevel(from: \"\\\"building\\\"\") == .building)\n        #expect(try decodeLevel(from: \"\\\"block\\\"\") == .block)\n        #expect(try decodeLevel(from: \"\\\"neighborhood\\\"\") == .neighborhood)\n        #expect(try decodeLevel(from: \"\\\"city\\\"\") == .city)\n        #expect(try decodeLevel(from: \"\\\"province\\\"\") == .province)\n        #expect(try decodeLevel(from: \"\\\"region\\\"\") == .province)\n        #expect(try decodeLevel(from: \"\\\"country\\\"\") == .region)\n        #expect(try decodeLevel(from: \"\\\"unknown\\\"\") == .block)\n        #expect(try decodeLevel(from: \"8\") == .building)\n        #expect(try decodeLevel(from: \"7\") == .block)\n        #expect(try decodeLevel(from: \"6\") == .neighborhood)\n        #expect(try decodeLevel(from: \"5\") == .city)\n        #expect(try decodeLevel(from: \"4\") == .province)\n        #expect(try decodeLevel(from: \"3\") == .region)\n        #expect(try decodeLevel(from: \"0\") == .region)\n        #expect(try decodeLevel(from: \"99\") == .block)\n        #expect(try decodeLevel(from: \"true\") == .block)\n    }\n\n    @Test\n    func geohashChannelAndChannelIDExposeStableAccessors() {\n        let channel = GeohashChannel(level: .city, geohash: \"u4pru\")\n\n        #expect(channel.id == \"city-u4pru\")\n        #expect(channel.displayName.contains(\"u4pru\"))\n        #expect(channel.displayName.contains(channel.level.displayName))\n\n        let mesh = ChannelID.mesh\n        #expect(mesh.displayName == \"Mesh\")\n        #expect(mesh.nostrGeohashTag == nil)\n        #expect(mesh.isMesh)\n        #expect(mesh.isLocation == false)\n\n        let location = ChannelID.location(channel)\n        #expect(location.displayName == channel.displayName)\n        #expect(location.nostrGeohashTag == \"u4pru\")\n        #expect(location.isMesh == false)\n        #expect(location.isLocation)\n    }\n\n    private func decodeLevel(from json: String) throws -> GeohashChannelLevel {\n        try JSONDecoder().decode(GeohashChannelLevel.self, from: Data(json.utf8))\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Protocols/PacketsTests.swift",
    "content": "import Foundation\nimport Testing\n\n@testable import bitchat\n\nstruct PacketsTests {\n    @Test\n    func announcementPacketRoundTripsNeighborsAndSkipsUnknownTLVs() throws {\n        let neighbors = (0..<12).map { index in\n            Data(repeating: UInt8(index), count: 8)\n        }\n        let packet = AnnouncementPacket(\n            nickname: \"alice\",\n            noisePublicKey: Data(repeating: 0x11, count: 32),\n            signingPublicKey: Data(repeating: 0x22, count: 32),\n            directNeighbors: neighbors\n        )\n\n        var encoded = try #require(packet.encode())\n        encoded.append(makeTLV(type: 0xFF, value: Data([0xAB])))\n\n        let decoded = try #require(AnnouncementPacket.decode(from: encoded))\n        #expect(decoded.nickname == \"alice\")\n        #expect(decoded.noisePublicKey == Data(repeating: 0x11, count: 32))\n        #expect(decoded.signingPublicKey == Data(repeating: 0x22, count: 32))\n        #expect(decoded.directNeighbors?.count == 10)\n        #expect(decoded.directNeighbors?.first == neighbors.first)\n        #expect(decoded.directNeighbors?.last == neighbors[9])\n    }\n\n    @Test\n    func announcementPacketEncodeRejectsOversizedFieldsAndInvalidNeighborGroups() {\n        let oversizedNickname = String(repeating: \"a\", count: 256)\n        let validKey = Data(repeating: 0x44, count: 32)\n\n        #expect(\n            AnnouncementPacket(\n                nickname: oversizedNickname,\n                noisePublicKey: validKey,\n                signingPublicKey: validKey,\n                directNeighbors: nil\n            ).encode() == nil\n        )\n\n        #expect(\n            AnnouncementPacket(\n                nickname: \"alice\",\n                noisePublicKey: Data(repeating: 0x55, count: 256),\n                signingPublicKey: validKey,\n                directNeighbors: nil\n            ).encode() == nil\n        )\n\n        #expect(\n            AnnouncementPacket(\n                nickname: \"alice\",\n                noisePublicKey: validKey,\n                signingPublicKey: Data(repeating: 0x66, count: 256),\n                directNeighbors: nil\n            ).encode() == nil\n        )\n\n        let invalidNeighborPacket = AnnouncementPacket(\n            nickname: \"alice\",\n            noisePublicKey: validKey,\n            signingPublicKey: validKey,\n            directNeighbors: [Data([0x01, 0x02, 0x03])]\n        )\n        let encodedWithoutNeighbors = AnnouncementPacket(\n            nickname: \"alice\",\n            noisePublicKey: validKey,\n            signingPublicKey: validKey,\n            directNeighbors: nil\n        ).encode()\n        #expect(invalidNeighborPacket.encode() == encodedWithoutNeighbors)\n    }\n\n    @Test\n    func announcementPacketDecodeRejectsMissingFieldsAndTruncation() throws {\n        let missingSigningKey = makeTLV(type: 0x01, value: Data(\"alice\".utf8))\n            + makeTLV(type: 0x02, value: Data(repeating: 0x11, count: 32))\n        #expect(AnnouncementPacket.decode(from: missingSigningKey) == nil)\n\n        let validPacket = try #require(\n            AnnouncementPacket(\n                nickname: \"alice\",\n                noisePublicKey: Data(repeating: 0x11, count: 32),\n                signingPublicKey: Data(repeating: 0x22, count: 32),\n                directNeighbors: nil\n            ).encode()\n        )\n        #expect(AnnouncementPacket.decode(from: validPacket.dropLast()) == nil)\n    }\n\n    @Test\n    func announcementPacketDecodeIgnoresInvalidNeighborLengths() throws {\n        var encoded = try #require(\n            AnnouncementPacket(\n                nickname: \"alice\",\n                noisePublicKey: Data(repeating: 0x11, count: 32),\n                signingPublicKey: Data(repeating: 0x22, count: 32),\n                directNeighbors: nil\n            ).encode()\n        )\n        encoded.append(makeTLV(type: 0x04, value: Data(repeating: 0x99, count: 7)))\n\n        let decoded = try #require(AnnouncementPacket.decode(from: encoded))\n        #expect(decoded.directNeighbors == nil)\n    }\n\n    @Test\n    func privateMessagePacketRejectsUnknownTypeAndTruncation() {\n        let unknownTLV = Data([0x7F, 0x01, 0x41])\n        #expect(PrivateMessagePacket.decode(from: unknownTLV) == nil)\n\n        let truncated = Data([0x00, 0x05, 0x61])\n        #expect(PrivateMessagePacket.decode(from: truncated) == nil)\n    }\n\n    private func makeTLV(type: UInt8, value: Data) -> Data {\n        var data = Data([type, UInt8(value.count)])\n        data.append(value)\n        return data\n    }\n}\n"
  },
  {
    "path": "bitchatTests/PublicMessagePipelineTests.swift",
    "content": "//\n// PublicMessagePipelineTests.swift\n// bitchatTests\n//\n// Tests for PublicMessagePipeline ordering and deduplication.\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\n@MainActor\nprivate final class TestPipelineDelegate: PublicMessagePipelineDelegate {\n    private let dedupService = MessageDeduplicationService()\n    var messages: [BitchatMessage] = []\n\n    func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {\n        messages\n    }\n\n    func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {\n        self.messages = messages\n    }\n\n    func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {\n        dedupService.normalizedContentKey(content)\n    }\n\n    func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {\n        dedupService.contentTimestamp(forKey: key)\n    }\n\n    func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {\n        dedupService.recordContentKey(key, timestamp: timestamp)\n    }\n\n    func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {}\n\n    func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {}\n\n    func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {}\n}\n\nstruct PublicMessagePipelineTests {\n\n    @Test @MainActor\n    func flush_sortsByTimestamp() async {\n        let pipeline = PublicMessagePipeline()\n        let delegate = TestPipelineDelegate()\n        pipeline.delegate = delegate\n\n        let earlier = Date().addingTimeInterval(-10)\n        let later = Date()\n\n        let messageA = BitchatMessage(\n            id: \"a\",\n            sender: \"A\",\n            content: \"Later\",\n            timestamp: later,\n            isRelay: false\n        )\n        let messageB = BitchatMessage(\n            id: \"b\",\n            sender: \"A\",\n            content: \"Earlier\",\n            timestamp: earlier,\n            isRelay: false\n        )\n\n        pipeline.enqueue(messageA)\n        pipeline.enqueue(messageB)\n        pipeline.flushIfNeeded()\n\n        #expect(delegate.messages.map { $0.id } == [\"b\", \"a\"])\n    }\n\n    @Test @MainActor\n    func flush_deduplicatesByContentWithinWindow() async {\n        let pipeline = PublicMessagePipeline()\n        let delegate = TestPipelineDelegate()\n        pipeline.delegate = delegate\n\n        let now = Date()\n        let messageA = BitchatMessage(\n            id: \"a\",\n            sender: \"A\",\n            content: \"Same\",\n            timestamp: now,\n            isRelay: false\n        )\n        let messageB = BitchatMessage(\n            id: \"b\",\n            sender: \"A\",\n            content: \"Same\",\n            timestamp: now.addingTimeInterval(0.2),\n            isRelay: false\n        )\n\n        pipeline.enqueue(messageA)\n        pipeline.enqueue(messageB)\n        pipeline.flushIfNeeded()\n\n        #expect(delegate.messages.count == 1)\n        #expect(delegate.messages.first?.content == \"Same\")\n    }\n\n    @Test @MainActor\n    func lateInsert_meshAppendsRecentOlderMessage() async {\n        let pipeline = PublicMessagePipeline()\n        let delegate = TestPipelineDelegate()\n        pipeline.delegate = delegate\n        pipeline.updateActiveChannel(.mesh)\n\n        let base = Date()\n        let newer = BitchatMessage(\n            id: \"new\",\n            sender: \"A\",\n            content: \"New\",\n            timestamp: base,\n            isRelay: false\n        )\n        let older = BitchatMessage(\n            id: \"old\",\n            sender: \"A\",\n            content: \"Old\",\n            timestamp: base.addingTimeInterval(-5),\n            isRelay: false\n        )\n\n        delegate.messages = [newer]\n        pipeline.enqueue(older)\n        pipeline.flushIfNeeded()\n\n        #expect(delegate.messages.map { $0.id } == [\"new\", \"old\"])\n    }\n\n    @Test @MainActor\n    func lateInsert_locationInsertsByTimestamp() async {\n        let pipeline = PublicMessagePipeline()\n        let delegate = TestPipelineDelegate()\n        pipeline.delegate = delegate\n        pipeline.updateActiveChannel(.location(GeohashChannel(level: .city, geohash: \"u4pruydq\")))\n\n        let base = Date()\n        let newer = BitchatMessage(\n            id: \"new\",\n            sender: \"A\",\n            content: \"New\",\n            timestamp: base,\n            isRelay: false\n        )\n        let older = BitchatMessage(\n            id: \"old\",\n            sender: \"A\",\n            content: \"Old\",\n            timestamp: base.addingTimeInterval(-5),\n            isRelay: false\n        )\n\n        delegate.messages = [newer]\n        pipeline.enqueue(older)\n        pipeline.flushIfNeeded()\n\n        #expect(delegate.messages.map { $0.id } == [\"old\", \"new\"])\n    }\n}\n"
  },
  {
    "path": "bitchatTests/PublicTimelineStoreTests.swift",
    "content": "import Foundation\nimport Testing\n@testable import bitchat\n\n@Suite(\"PublicTimelineStore Tests\")\nstruct PublicTimelineStoreTests {\n\n    @Test(\"Mesh timeline deduplicates and trims to cap\")\n    func meshTimelineDeduplicatesAndTrims() {\n        var store = PublicTimelineStore(meshCap: 2, geohashCap: 2)\n        let first = TestHelpers.createTestMessage(content: \"one\")\n        let second = TestHelpers.createTestMessage(content: \"two\")\n        let third = TestHelpers.createTestMessage(content: \"three\")\n\n        store.append(first, to: .mesh)\n        store.append(second, to: .mesh)\n        store.append(first, to: .mesh)\n        store.append(third, to: .mesh)\n\n        let messages = store.messages(for: .mesh)\n        #expect(messages.map(\\.content) == [\"two\", \"three\"])\n    }\n\n    @Test(\"Geohash appendIfAbsent remove and clear work together\")\n    func geohashStoreSupportsAppendRemoveAndClear() {\n        var store = PublicTimelineStore(meshCap: 2, geohashCap: 3)\n        let geohash = \"u4pruydq\"\n        let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))\n        let first = TestHelpers.createTestMessage(content: \"geo one\")\n        let second = TestHelpers.createTestMessage(content: \"geo two\")\n\n        let didAppendFirst = store.appendIfAbsent(first, toGeohash: geohash)\n        let didAppendDuplicate = store.appendIfAbsent(first, toGeohash: geohash)\n\n        #expect(didAppendFirst)\n        #expect(!didAppendDuplicate)\n        store.append(second, toGeohash: geohash)\n        let removed = store.removeMessage(withID: first.id)\n\n        #expect(removed?.id == first.id)\n        #expect(store.messages(for: channel).map(\\.content) == [\"geo two\"])\n\n        store.clear(channel: channel)\n        #expect(store.messages(for: channel).isEmpty)\n    }\n\n    @Test(\"Mutate geohash updates stored messages in place\")\n    func mutateGeohashAppliesTransformation() {\n        var store = PublicTimelineStore(meshCap: 2, geohashCap: 3)\n        let geohash = \"u4pruydq\"\n        let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))\n        let first = TestHelpers.createTestMessage(content: \"geo one\")\n\n        store.append(first, toGeohash: geohash)\n        store.mutateGeohash(geohash) { timeline in\n            timeline.append(TestHelpers.createTestMessage(content: \"geo two\"))\n        }\n\n        #expect(store.messages(for: channel).map(\\.content) == [\"geo one\", \"geo two\"])\n    }\n\n    @Test(\"Queued geohash system messages drain once\")\n    func pendingGeohashSystemMessagesDrainOnce() {\n        var store = PublicTimelineStore(meshCap: 1, geohashCap: 1)\n\n        store.queueGeohashSystemMessage(\"first\")\n        store.queueGeohashSystemMessage(\"second\")\n\n        #expect(store.drainPendingGeohashSystemMessages() == [\"first\", \"second\"])\n        #expect(store.drainPendingGeohashSystemMessages().isEmpty)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/README.md",
    "content": "# Test Harness Guide\n\nThis 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.\n\n## In-Memory Bus\n\n- **File:** `bitchatTests/Mocks/MockBLEService.swift`\n- **Registry/Adjacency:** Global `registry` maps `peerID` to a `MockBLEService` instance; `adjacency` records simulated links between peers.\n- **Setup:** Call `MockBLEService.resetTestBus()` in `setUp()` to clear state between tests.\n- **Topology:** Use `simulateConnectedPeer(_:)` and `simulateDisconnectedPeer(_:)` to add/remove links. `connectFullMesh()` helpers in tests build larger topologies.\n- **Handlers:** Tests can observe data via `messageDeliveryHandler` (decoded `BitchatMessage`) and `packetDeliveryHandler` (raw `BitchatPacket`).\n- **De‑duplication:** A thread-safe `seenMessageIDs` prevents duplicate deliveries during flooding/relays.\n\n## Broadcast Flooding\n\n- **Flag:** `MockBLEService.autoFloodEnabled`\n- **Intent:** When `true`, public broadcasts propagate across the entire connected component (ignores TTL for reach) while still de‑duping to prevent loops.\n- **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`).\n\n## Rehandshake Flow (Noise)\n\n- **Why:** The legacy NACK recovery path was removed; recovery now relies on Noise session rehandshake after decrypt failure or desync.\n- **Manager:** `NoiseSessionManager` manages per-peer sessions.\n- **Pattern:** On decrypt failure, proactively clear the local session and re-initiate a handshake. The peer accepts and replaces their session.\n- **Test:** `IntegrationTests.testRehandshakeAfterDecryptionFailure`\n  - Corrupts ciphertext to induce a decrypt error.\n  - Calls `removeSession(for:)` on the initiator’s manager before `initiateHandshake(with:)` to avoid `alreadyEstablished`.\n  - Verifies encrypt/decrypt succeeds post-rehandshake.\n\n## Tips\n\n- **Determinism:** Add small async delays only where handler installation/topology changes could race the first send.\n- **Scoping:** Keep `autoFloodEnabled` toggled only within Integration tests; always reset in `tearDown()` to avoid cross-test contamination.\n- **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.\n\n## Quick Start\n\n- Create nodes and connect them:\n  - `let svc = MockBLEService(); svc.myPeerID = \"PEER1\"`\n  - `svc.simulateConnectedPeer(\"PEER2\")`\n- Observe messages:\n  - `svc.messageDeliveryHandler = { msg in /* asserts */ }`\n- Enable broadcast flooding for Integration suites only:\n  - `MockBLEService.autoFloodEnabled = true`\n\n"
  },
  {
    "path": "bitchatTests/ReadReceiptTests.swift",
    "content": "import Foundation\nimport Testing\n@testable import bitchat\n\n@Suite(\"ReadReceipt Tests\")\nstruct ReadReceiptTests {\n\n    @Test(\"JSON encode and decode round-trip stable fields\")\n    func jsonRoundTrip() throws {\n        let receipt = ReadReceipt(\n            originalMessageID: UUID().uuidString,\n            readerID: PeerID(str: \"0123456789abcdef\"),\n            readerNickname: \"Alice\"\n        )\n\n        let encoded = try #require(receipt.encode(), \"Receipt should encode to JSON\")\n        let decoded = try #require(ReadReceipt.decode(from: encoded), \"Receipt should decode from JSON\")\n\n        #expect(decoded.originalMessageID == receipt.originalMessageID)\n        #expect(decoded.receiptID == receipt.receiptID)\n        #expect(decoded.readerID == receipt.readerID)\n        #expect(decoded.readerNickname == receipt.readerNickname)\n        #expect(abs(decoded.timestamp.timeIntervalSince(receipt.timestamp)) < 0.001)\n    }\n\n    @Test(\"Binary encode and decode round-trip stable fields\")\n    func binaryRoundTrip() throws {\n        let receipt = ReadReceipt(\n            originalMessageID: UUID().uuidString,\n            readerID: PeerID(str: \"fedcba9876543210\"),\n            readerNickname: \"Bob\"\n        )\n\n        let decoded = try #require(\n            ReadReceipt.fromBinaryData(receipt.toBinaryData()),\n            \"Receipt should decode from binary data\"\n        )\n\n        #expect(decoded.originalMessageID == receipt.originalMessageID.uppercased())\n        #expect(decoded.receiptID == receipt.receiptID.uppercased())\n        #expect(decoded.readerID == receipt.readerID)\n        #expect(decoded.readerNickname == receipt.readerNickname)\n    }\n\n    @Test(\"Binary decode rejects truncated data\")\n    func binaryDecodeRejectsTruncatedData() {\n        #expect(ReadReceipt.fromBinaryData(Data()) == nil)\n        #expect(ReadReceipt.fromBinaryData(Data(repeating: 0, count: 48)) == nil)\n    }\n\n    @Test(\"Binary decode rejects stale timestamps\")\n    func binaryDecodeRejectsStaleTimestamp() {\n        let receipt = ReadReceipt(\n            originalMessageID: UUID().uuidString,\n            readerID: PeerID(str: \"0011223344556677\"),\n            readerNickname: \"Carol\"\n        )\n        var data = receipt.toBinaryData()\n\n        data.replaceSubrange(40..<48, with: Data(repeating: 0, count: 8))\n\n        #expect(ReadReceipt.fromBinaryData(data) == nil)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/AutocompleteServiceTests.swift",
    "content": "import Foundation\nimport Testing\n@testable import bitchat\n\n@Suite(\"AutocompleteService Tests\")\nstruct AutocompleteServiceTests {\n\n    @Test(\"Mention suggestions are sorted, capped, and include replacement range\")\n    func mentionSuggestionsAreSortedAndCapped() {\n        let service = AutocompleteService()\n        let text = \"hi @al\"\n\n        let result = service.getSuggestions(\n            for: text,\n            peers: [\"zoe\", \"alice\", \"albert\", \"bob\", \"alex\", \"ally\", \"alpha\"],\n            cursorPosition: text.count\n        )\n\n        #expect(result.suggestions == [\"@albert\", \"@alex\", \"@alice\", \"@ally\", \"@alpha\"])\n        #expect(result.range == NSRange(location: 3, length: 3))\n    }\n\n    @Test(\"Suggestions are empty when cursor is not at a trailing mention\")\n    func suggestionsRequireTrailingMentionContext() {\n        let service = AutocompleteService()\n        let text = \"hi @al there\"\n\n        let result = service.getSuggestions(\n            for: text,\n            peers: [\"alice\", \"albert\"],\n            cursorPosition: text.count\n        )\n\n        #expect(result.suggestions.isEmpty)\n        #expect(result.range == nil)\n    }\n\n    @Test(\"Applying suggestions replaces the range and adds command spacing only when needed\")\n    func applySuggestionReplacesRangeAndHandlesCommandSpacing() {\n        let service = AutocompleteService()\n\n        let mentionResult = service.applySuggestion(\"@alice\", to: \"hi @al\", range: NSRange(location: 3, length: 3))\n        let msgCommand = service.applySuggestion(\"/msg\", to: \"/m\", range: NSRange(location: 0, length: 2))\n        let clearCommand = service.applySuggestion(\"/clear\", to: \"/c\", range: NSRange(location: 0, length: 2))\n\n        #expect(mentionResult == \"hi @alice\")\n        #expect(msgCommand == \"/msg \")\n        #expect(clearCommand == \"/clear\")\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/FavoritesPersistenceServiceTests.swift",
    "content": "import XCTest\n@testable import bitchat\n\n@MainActor\nfinal class FavoritesPersistenceServiceTests: XCTestCase {\n    private let storageKey = \"chat.bitchat.favorites\"\n    private let serviceKey = \"chat.bitchat.favorites\"\n\n    func test_addFavorite_persistsAndPostsNotification() throws {\n        let keychain = MockKeychain()\n        let service = FavoritesPersistenceService(keychain: keychain)\n        let peerKey = Data((0..<32).map(UInt8.init))\n        let expectation = expectation(forNotification: .favoriteStatusChanged, object: nil)\n\n        service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: \"npub1alice\", peerNickname: \"Alice\")\n\n        wait(for: [expectation], timeout: 1.0)\n        XCTAssertTrue(service.isFavorite(peerKey))\n        XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, \"Alice\")\n        XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey))\n    }\n\n    func test_removeFavorite_preservesRelationshipWhenPeerStillFavoritesUs() {\n        let service = FavoritesPersistenceService(keychain: MockKeychain())\n        let peerKey = Data((32..<64).map(UInt8.init))\n\n        service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: \"Bob\")\n        service.addFavorite(peerNoisePublicKey: peerKey, peerNickname: \"Bob\")\n        service.removeFavorite(peerNoisePublicKey: peerKey)\n\n        let relationship = service.getFavoriteStatus(for: peerKey)\n        XCTAssertNotNil(relationship)\n        XCTAssertEqual(relationship?.peerNickname, \"Bob\")\n        XCTAssertFalse(relationship?.isFavorite ?? true)\n        XCTAssertTrue(relationship?.theyFavoritedUs ?? false)\n    }\n\n    func test_updatePeerFavoritedUs_removesRelationshipWhenNeitherSideFavorites() {\n        let service = FavoritesPersistenceService(keychain: MockKeychain())\n        let peerKey = Data((64..<96).map(UInt8.init))\n\n        service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: \"Carol\")\n        XCTAssertNotNil(service.getFavoriteStatus(for: peerKey))\n\n        service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: false, peerNickname: \"Carol\")\n\n        XCTAssertNil(service.getFavoriteStatus(for: peerKey))\n        XCTAssertFalse(service.isMutualFavorite(peerKey))\n    }\n\n    func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() {\n        let service = FavoritesPersistenceService(keychain: MockKeychain())\n        let peerKey = Data((96..<128).map(UInt8.init))\n\n        service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: \"npub1dan\", peerNickname: \"Dan\")\n        service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: \"Dan\")\n\n        let relationship = service.getFavoriteStatus(forPeerID: PeerID(publicKey: peerKey))\n        XCTAssertEqual(relationship?.peerNickname, \"Dan\")\n        XCTAssertTrue(service.isMutualFavorite(peerKey))\n    }\n\n    func test_init_deduplicatesPersistedRelationshipsByPublicKey() throws {\n        let keychain = MockKeychain()\n        let peerKey = Data((128..<160).map(UInt8.init))\n        let older = FavoritesPersistenceService.FavoriteRelationship(\n            peerNoisePublicKey: peerKey,\n            peerNostrPublicKey: nil,\n            peerNickname: \"Older\",\n            isFavorite: true,\n            theyFavoritedUs: false,\n            favoritedAt: Date(timeIntervalSince1970: 100),\n            lastUpdated: Date(timeIntervalSince1970: 100)\n        )\n        let newer = FavoritesPersistenceService.FavoriteRelationship(\n            peerNoisePublicKey: peerKey,\n            peerNostrPublicKey: \"npub1newer\",\n            peerNickname: \"Newer\",\n            isFavorite: true,\n            theyFavoritedUs: true,\n            favoritedAt: Date(timeIntervalSince1970: 100),\n            lastUpdated: Date(timeIntervalSince1970: 200)\n        )\n        let encoded = try JSONEncoder().encode([older, newer])\n        keychain.save(key: storageKey, data: encoded, service: serviceKey, accessible: nil)\n\n        let service = FavoritesPersistenceService(keychain: keychain)\n\n        XCTAssertEqual(service.favorites.count, 1)\n        XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, \"Newer\")\n        XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNostrPublicKey, \"npub1newer\")\n\n        let cleaned = try XCTUnwrap(keychain.load(key: storageKey, service: serviceKey))\n        let decoded = try JSONDecoder().decode([FavoritesPersistenceService.FavoriteRelationship].self, from: cleaned)\n        XCTAssertEqual(decoded.count, 1)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/GeohashPresenceServiceTests.swift",
    "content": "import Combine\nimport XCTest\n@testable import bitchat\n\n@MainActor\nfinal class GeohashPresenceServiceTests: XCTestCase {\n    func test_start_schedulesHeartbeatUsingConfiguredInterval() {\n        let scheduler = MockGeohashPresenceScheduler()\n        let service = makeService(scheduler: scheduler, loopMinInterval: 42, loopMaxInterval: 42)\n\n        service.start()\n\n        XCTAssertEqual(scheduler.intervals, [42])\n    }\n\n    func test_handleLocationChange_invalidatesExistingTimerAndSchedulesQuickRefresh() {\n        let scheduler = MockGeohashPresenceScheduler()\n        let service = makeService(scheduler: scheduler, loopMinInterval: 40, loopMaxInterval: 40)\n\n        service.start()\n        let originalTimer = scheduler.timers.first\n\n        service.handleLocationChange()\n\n        XCTAssertEqual(scheduler.intervals, [40, 5])\n        XCTAssertEqual(originalTimer?.invalidateCallCount, 1)\n    }\n\n    func test_handleConnectivityChange_onlySchedulesWhenExistingTimerIsMissingOrInvalid() {\n        let scheduler = MockGeohashPresenceScheduler()\n        let service = makeService(scheduler: scheduler, loopMinInterval: 33, loopMaxInterval: 33)\n\n        service.start()\n        service.handleConnectivityChange()\n        XCTAssertEqual(scheduler.intervals, [33])\n\n        scheduler.timers.last?.invalidate()\n        service.handleConnectivityChange()\n        XCTAssertEqual(scheduler.intervals, [33, 33])\n    }\n\n    func test_performHeartbeat_broadcastsOnlyAllowedPrecisionChannels() async throws {\n        let identity = try NostrIdentity.generate()\n        let scheduler = MockGeohashPresenceScheduler()\n        var sentGeohashes: [String] = []\n        var lookedUpGeohashes: [String] = []\n        var sleptNanoseconds: [UInt64] = []\n        let channels = [\n            GeohashChannel(level: .region, geohash: \"9q\"),\n            GeohashChannel(level: .province, geohash: \"9q8y\"),\n            GeohashChannel(level: .city, geohash: \"9q8yy\"),\n            GeohashChannel(level: .neighborhood, geohash: \"9q8yyk\"),\n            GeohashChannel(level: .block, geohash: \"9q8yyk8\"),\n            GeohashChannel(level: .building, geohash: \"9q8yyk8y\")\n        ]\n        let service = makeService(\n            scheduler: scheduler,\n            availableChannels: channels,\n            deriveIdentity: { _ in identity },\n            relayLookup: { geohash, _ in\n                lookedUpGeohashes.append(geohash)\n                return [\"wss://\\(geohash).example\"]\n            },\n            relaySender: { event, _ in\n                let geohash = event.tags.first(where: { $0.first == \"g\" })?[1]\n                if let geohash {\n                    sentGeohashes.append(geohash)\n                }\n            },\n            sleeper: { nanoseconds in\n                sleptNanoseconds.append(nanoseconds)\n            },\n            loopMinInterval: 17,\n            loopMaxInterval: 17,\n            burstMinDelay: 0,\n            burstMaxDelay: 0\n        )\n\n        service.performHeartbeat()\n\n        let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }\n        XCTAssertTrue(sentAllAllowedChannels)\n        XCTAssertEqual(Set(sentGeohashes), Set([\"9q\", \"9q8y\", \"9q8yy\"]))\n        XCTAssertEqual(Set(lookedUpGeohashes), Set([\"9q\", \"9q8y\", \"9q8yy\"]))\n        XCTAssertEqual(sleptNanoseconds.count, 3)\n        XCTAssertEqual(scheduler.intervals, [17])\n    }\n\n    func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {\n        let scheduler = MockGeohashPresenceScheduler()\n        var sendCount = 0\n        let service = makeService(\n            scheduler: scheduler,\n            torIsReady: { false },\n            relaySender: { _, _ in sendCount += 1 },\n            loopMinInterval: 21,\n            loopMaxInterval: 21\n        )\n\n        service.performHeartbeat()\n        try? await Task.sleep(nanoseconds: 20_000_000)\n\n        XCTAssertEqual(sendCount, 0)\n        XCTAssertEqual(scheduler.intervals, [21])\n    }\n\n    func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {\n        let scheduler = MockGeohashPresenceScheduler()\n        var sendCount = 0\n        let service = makeService(\n            scheduler: scheduler,\n            torIsForeground: { false },\n            relaySender: { _, _ in sendCount += 1 },\n            loopMinInterval: 22,\n            loopMaxInterval: 22\n        )\n\n        service.performHeartbeat()\n        try? await Task.sleep(nanoseconds: 20_000_000)\n\n        XCTAssertEqual(sendCount, 0)\n        XCTAssertEqual(scheduler.intervals, [22])\n    }\n\n    func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {\n        let identity = try NostrIdentity.generate()\n        var sendCount = 0\n        let service = makeService(\n            scheduler: MockGeohashPresenceScheduler(),\n            deriveIdentity: { _ in identity },\n            relayLookup: { _, _ in [] },\n            relaySender: { _, _ in sendCount += 1 }\n        )\n\n        service.broadcastPresence(for: \"9q8yy\")\n        try? await Task.sleep(nanoseconds: 20_000_000)\n\n        XCTAssertEqual(sendCount, 0)\n    }\n\n    func test_broadcastPresence_skipsSendWhenIdentityDerivationFails() async {\n        enum PresenceError: Error { case failed }\n\n        var sendCount = 0\n        let service = makeService(\n            scheduler: MockGeohashPresenceScheduler(),\n            deriveIdentity: { _ in throw PresenceError.failed },\n            relaySender: { _, _ in sendCount += 1 }\n        )\n\n        service.broadcastPresence(for: \"9q8yy\")\n        try? await Task.sleep(nanoseconds: 20_000_000)\n\n        XCTAssertEqual(sendCount, 0)\n    }\n\n    private func makeService(\n        scheduler: MockGeohashPresenceScheduler,\n        availableChannels: [GeohashChannel] = [\n            GeohashChannel(level: .city, geohash: \"9q8yy\")\n        ],\n        torIsReady: @escaping () -> Bool = { true },\n        torIsForeground: @escaping () -> Bool = { true },\n        deriveIdentity: @escaping (String) throws -> NostrIdentity = { _ in try NostrIdentity.generate() },\n        relayLookup: @escaping (String, Int) -> [String] = { geohash, _ in [\"wss://\\(geohash).example\"] },\n        relaySender: @escaping (NostrEvent, [String]) -> Void = { _, _ in },\n        sleeper: @escaping (UInt64) async -> Void = { _ in },\n        loopMinInterval: TimeInterval = 40,\n        loopMaxInterval: TimeInterval = 40,\n        burstMinDelay: TimeInterval = 0,\n        burstMaxDelay: TimeInterval = 0\n    ) -> GeohashPresenceService {\n        let locationSubject = PassthroughSubject<[GeohashChannel], Never>()\n        let torReadySubject = PassthroughSubject<Void, Never>()\n        return GeohashPresenceService(\n            availableChannelsProvider: { availableChannels },\n            locationChanges: locationSubject.eraseToAnyPublisher(),\n            torReadyPublisher: torReadySubject.eraseToAnyPublisher(),\n            torIsReady: torIsReady,\n            torIsForeground: torIsForeground,\n            deriveIdentity: deriveIdentity,\n            relayLookup: relayLookup,\n            relaySender: relaySender,\n            sleeper: sleeper,\n            scheduleTimer: scheduler.schedule(interval:handler:),\n            loopMinInterval: loopMinInterval,\n            loopMaxInterval: loopMaxInterval,\n            burstMinDelay: burstMinDelay,\n            burstMaxDelay: burstMaxDelay\n        )\n    }\n\n    private func waitUntil(\n        timeout: TimeInterval = 1.0,\n        condition: @escaping @MainActor () -> Bool\n    ) async -> Bool {\n        let deadline = Date().addingTimeInterval(timeout)\n        while Date() < deadline {\n            if condition() {\n                return true\n            }\n            try? await Task.sleep(nanoseconds: 10_000_000)\n        }\n        return condition()\n    }\n}\n\nprivate final class MockGeohashPresenceScheduler {\n    private(set) var intervals: [TimeInterval] = []\n    private(set) var timers: [MockGeohashPresenceTimer] = []\n\n    func schedule(interval: TimeInterval, handler: @escaping () -> Void) -> GeohashPresenceTimerProtocol {\n        intervals.append(interval)\n        let timer = MockGeohashPresenceTimer(handler: handler)\n        timers.append(timer)\n        return timer\n    }\n}\n\nprivate final class MockGeohashPresenceTimer: GeohashPresenceTimerProtocol {\n    private let handler: () -> Void\n    private(set) var isValid = true\n    private(set) var invalidateCallCount = 0\n\n    init(handler: @escaping () -> Void) {\n        self.handler = handler\n    }\n\n    func invalidate() {\n        invalidateCallCount += 1\n        isValid = false\n    }\n\n    func fire() {\n        handler()\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/LocationStateManagerTests.swift",
    "content": "import CoreLocation\nimport MapKit\nimport XCTest\n@testable import bitchat\n\n@MainActor\nfinal class LocationStateManagerTests: XCTestCase {\n    func test_loadPersistedState_normalizesBookmarksAndRestoresTeleportedSelection() async throws {\n        let storage = makeStorage()\n        let selected = ChannelID.location(GeohashChannel(level: .city, geohash: \"u4pru\"))\n        storage.set(try JSONEncoder().encode(selected), forKey: \"locationChannel.selected\")\n        storage.set(try JSONEncoder().encode([\"u4pru\"]), forKey: \"locationChannel.teleportedSet\")\n        storage.set(try JSONEncoder().encode([\"#U4PRU\", \"u4pru\", \"\"]), forKey: \"locationChannel.bookmarks\")\n\n        let manager = LocationStateManager(\n            storage: storage,\n            locationManager: MockLocationManager(authorizationStatus: .denied),\n            geocoder: MockLocationGeocoder(),\n            shouldInitializeCoreLocation: true\n        )\n\n        let deniedLoaded = await waitUntil { manager.permissionState == .denied }\n        XCTAssertTrue(deniedLoaded)\n        XCTAssertEqual(manager.bookmarks, [\"u4pru\"])\n        XCTAssertEqual(manager.selectedChannel, selected)\n        let teleportedLoaded = await waitUntil { manager.teleported }\n        XCTAssertTrue(teleportedLoaded)\n    }\n\n    func test_enableLocationChannels_requestsAuthorizationWhenStatusIsUndetermined() {\n        let locationManager = MockLocationManager(authorizationStatus: .notDetermined)\n        let manager = LocationStateManager(\n            storage: makeStorage(),\n            locationManager: locationManager,\n            geocoder: MockLocationGeocoder(),\n            shouldInitializeCoreLocation: true\n        )\n\n        manager.enableLocationChannels()\n\n        XCTAssertEqual(locationManager.requestAuthorizationCallCount, 1)\n        XCTAssertEqual(locationManager.requestLocationCallCount, 0)\n    }\n\n    func test_enableLocationChannels_requestsOneShotLocationWhenAuthorized() async {\n        let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)\n        let manager = LocationStateManager(\n            storage: makeStorage(),\n            locationManager: locationManager,\n            geocoder: MockLocationGeocoder(),\n            shouldInitializeCoreLocation: true\n        )\n\n        let authorizedLoaded = await waitUntil { manager.permissionState == .authorized }\n        XCTAssertTrue(authorizedLoaded)\n\n        manager.enableLocationChannels()\n\n        XCTAssertEqual(locationManager.requestLocationCallCount, 1)\n        XCTAssertEqual(manager.permissionState, .authorized)\n    }\n\n    func test_beginAndEndLiveRefresh_adjustLocationManagerMode() async {\n        let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)\n        let manager = LocationStateManager(\n            storage: makeStorage(),\n            locationManager: locationManager,\n            geocoder: MockLocationGeocoder(),\n            shouldInitializeCoreLocation: true\n        )\n\n        let authorizedLoaded = await waitUntil { manager.permissionState == .authorized }\n        XCTAssertTrue(authorizedLoaded)\n\n        manager.beginLiveRefresh()\n\n        XCTAssertEqual(locationManager.startUpdatingLocationCallCount, 1)\n        XCTAssertEqual(locationManager.requestLocationCallCount, 1)\n        XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyNearestTenMeters)\n        XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterLiveMeters)\n\n        manager.endLiveRefresh()\n\n        XCTAssertEqual(locationManager.stopUpdatingLocationCallCount, 1)\n        XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyHundredMeters)\n        XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters)\n    }\n\n    func test_didUpdateLocations_computesChannelsAndReverseGeocodesFriendlyNames() async {\n        let geocoder = MockLocationGeocoder()\n        geocoder.enqueue(\n            placemarks: [\n                makePlacemark(\n                    country: \"United States\",\n                    administrativeArea: \"Hawaii\",\n                    locality: \"Honolulu\",\n                    subLocality: \"Waikiki\",\n                    name: \"Hilton Hawaiian Village\"\n                )\n            ]\n        )\n        let manager = LocationStateManager(\n            storage: makeStorage(),\n            locationManager: MockLocationManager(authorizationStatus: .authorizedAlways),\n            geocoder: geocoder,\n            shouldInitializeCoreLocation: true\n        )\n        let location = CLLocation(latitude: 21.2850, longitude: -157.8357)\n\n        manager.locationManager(CLLocationManager(), didUpdateLocations: [location])\n\n        let channelsAndNamesLoaded = await waitUntil {\n            manager.availableChannels.count == GeohashChannelLevel.allCases.count &&\n            manager.locationNames[.city] == \"Honolulu\" &&\n            manager.locationNames[.building] == \"Hilton Hawaiian Village\"\n        }\n        XCTAssertTrue(channelsAndNamesLoaded)\n        XCTAssertEqual(geocoder.cancelCallCount, 1)\n        XCTAssertEqual(geocoder.reverseRequests.count, 1)\n        XCTAssertEqual(manager.availableChannels.map(\\.geohash.count), GeohashChannelLevel.allCases.map(\\.precision))\n        XCTAssertEqual(manager.locationNames[.region], \"United States\")\n        XCTAssertEqual(manager.locationNames[.province], \"Hawaii\")\n        XCTAssertEqual(manager.locationNames[.city], \"Honolulu\")\n        XCTAssertEqual(manager.locationNames[.neighborhood], \"Waikiki\")\n        XCTAssertEqual(manager.locationNames[.block], \"Waikiki\")\n        XCTAssertEqual(manager.locationNames[.building], \"Hilton Hawaiian Village\")\n    }\n\n    func test_selectingInRegionChannel_clearsTeleportedPersistence() async {\n        let storage = makeStorage()\n        let manager = LocationStateManager(\n            storage: storage,\n            locationManager: MockLocationManager(authorizationStatus: .authorizedAlways),\n            geocoder: MockLocationGeocoder(),\n            shouldInitializeCoreLocation: true\n        )\n        let coordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)\n        let cityGeohash = Geohash.encode(\n            latitude: coordinate.latitude,\n            longitude: coordinate.longitude,\n            precision: GeohashChannelLevel.city.precision\n        )\n        let channel = GeohashChannel(level: .city, geohash: cityGeohash)\n\n        manager.locationManager(CLLocationManager(), didUpdateLocations: [CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)])\n        let channelAvailable = await waitUntil { manager.availableChannels.contains(channel) }\n        XCTAssertTrue(channelAvailable)\n\n        manager.markTeleported(for: cityGeohash, true)\n        manager.select(.location(channel))\n\n        let selectionSettled = await waitUntil {\n            manager.selectedChannel == .location(channel) && !manager.teleported\n        }\n        XCTAssertTrue(selectionSettled)\n\n        let reloaded = LocationStateManager(\n            storage: storage,\n            locationManager: MockLocationManager(authorizationStatus: .denied),\n            geocoder: MockLocationGeocoder(),\n            shouldInitializeCoreLocation: true\n        )\n\n        let reloadedDenied = await waitUntil { reloaded.permissionState == .denied }\n        XCTAssertTrue(reloadedDenied)\n        XCTAssertEqual(reloaded.selectedChannel, .location(channel))\n        XCTAssertFalse(reloaded.teleported)\n    }\n\n    func test_addBookmark_lowPrecisionResolvesCompositeAdminName() async {\n        let geocoder = MockLocationGeocoder()\n        geocoder.enqueue(placemarks: [makePlacemark(country: \"United States\", administrativeArea: \"California\")])\n        geocoder.enqueue(placemarks: [makePlacemark(country: \"United States\", administrativeArea: \"Nevada\")])\n        geocoder.enqueue(placemarks: [makePlacemark(country: \"United States\", administrativeArea: \"California\")])\n        geocoder.enqueue(placemarks: [makePlacemark(country: \"United States\", administrativeArea: \"Arizona\")])\n        geocoder.enqueue(placemarks: [makePlacemark(country: \"United States\", administrativeArea: \"Nevada\")])\n        let manager = LocationStateManager(\n            storage: makeStorage(),\n            locationManager: MockLocationManager(authorizationStatus: .denied),\n            geocoder: geocoder,\n            shouldInitializeCoreLocation: false\n        )\n\n        manager.addBookmark(\"9q\")\n\n        let bookmarkResolved = await waitUntil { manager.bookmarkNames[\"9q\"] == \"California and Nevada\" }\n        XCTAssertTrue(bookmarkResolved)\n        XCTAssertEqual(geocoder.reverseRequests.count, 5)\n        XCTAssertEqual(manager.bookmarks, [\"9q\"])\n    }\n\n    private func makeStorage() -> UserDefaults {\n        let suiteName = \"LocationStateManagerTests-\\(UUID().uuidString)\"\n        let storage = UserDefaults(suiteName: suiteName)!\n        storage.removePersistentDomain(forName: suiteName)\n        addTeardownBlock {\n            storage.removePersistentDomain(forName: suiteName)\n        }\n        return storage\n    }\n\n    private func makePlacemark(\n        country: String? = nil,\n        administrativeArea: String? = nil,\n        locality: String? = nil,\n        subLocality: String? = nil,\n        name: String? = nil\n    ) -> CLPlacemark {\n        var address: [String: Any] = [:]\n        if let country {\n            address[\"Country\"] = country\n        }\n        if let administrativeArea {\n            address[\"State\"] = administrativeArea\n        }\n        if let locality {\n            address[\"City\"] = locality\n        }\n        if let subLocality {\n            address[\"SubLocality\"] = subLocality\n        }\n        if let name {\n            address[\"Name\"] = name\n        }\n        let placemark = MKPlacemark(\n            coordinate: CLLocationCoordinate2D(latitude: 21.2850, longitude: -157.8357),\n            addressDictionary: address\n        )\n        return CLPlacemark(placemark: placemark)\n    }\n\n    private func waitUntil(\n        timeout: TimeInterval = 1.0,\n        condition: @escaping @MainActor () -> Bool\n    ) async -> Bool {\n        let deadline = Date().addingTimeInterval(timeout)\n        while Date() < deadline {\n            if condition() {\n                return true\n            }\n            try? await Task.sleep(nanoseconds: 10_000_000)\n        }\n        return condition()\n    }\n}\n\nprivate final class MockLocationManager: LocationStateManaging {\n    weak var delegate: CLLocationManagerDelegate?\n    var desiredAccuracy: CLLocationAccuracy = 0\n    var distanceFilter: CLLocationDistance = 0\n    var authorizationStatus: CLAuthorizationStatus\n    private(set) var requestAuthorizationCallCount = 0\n    private(set) var requestLocationCallCount = 0\n    private(set) var startUpdatingLocationCallCount = 0\n    private(set) var stopUpdatingLocationCallCount = 0\n\n    init(authorizationStatus: CLAuthorizationStatus) {\n        self.authorizationStatus = authorizationStatus\n    }\n\n    func requestWhenInUseAuthorization() {\n        requestAuthorizationCallCount += 1\n    }\n\n    func requestLocation() {\n        requestLocationCallCount += 1\n    }\n\n    func startUpdatingLocation() {\n        startUpdatingLocationCallCount += 1\n    }\n\n    func stopUpdatingLocation() {\n        stopUpdatingLocationCallCount += 1\n    }\n}\n\nprivate final class MockLocationGeocoder: LocationStateGeocoding {\n    private struct Response {\n        let placemarks: [CLPlacemark]?\n        let error: Error?\n    }\n\n    private(set) var cancelCallCount = 0\n    private(set) var reverseRequests: [CLLocation] = []\n    private var responses: [Response] = []\n\n    func enqueue(placemarks: [CLPlacemark]?, error: Error? = nil) {\n        responses.append(Response(placemarks: placemarks, error: error))\n    }\n\n    func cancelGeocode() {\n        cancelCallCount += 1\n    }\n\n    func reverseGeocodeLocation(\n        _ location: CLLocation,\n        completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void\n    ) {\n        reverseRequests.append(location)\n        let response = responses.isEmpty ? Response(placemarks: nil, error: nil) : responses.removeFirst()\n        completionHandler(response.placemarks, response.error)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/MeshTopologyTrackerTests.swift",
    "content": "//\n// MeshTopologyTrackerTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\nstruct MeshTopologyTrackerTests {\n    private func hex(_ value: String) throws -> Data {\n        try #require(Data(hexString: value))\n    }\n\n    @Test func directLinkProducesRoute() throws {\n        let tracker = MeshTopologyTracker()\n        let a = try hex(\"0102030405060708\")\n        let b = try hex(\"1112131415161718\")\n\n        // Bidirectional announcement\n        tracker.updateNeighbors(for: a, neighbors: [b])\n        tracker.updateNeighbors(for: b, neighbors: [a])\n        \n        let route = try #require(tracker.computeRoute(from: a, to: b))\n        // Direct connection returns empty route (no intermediate hops)\n        #expect(route == [])\n    }\n\n    @Test func multiHopRouteComputation() throws {\n        let tracker = MeshTopologyTracker()\n        let a = try hex(\"0001020304050607\")\n        let b = try hex(\"1011121314151617\")\n        let c = try hex(\"2021222324252627\")\n        let d = try hex(\"3031323334353637\")\n\n        // Bidirectional announcements for A-B, B-C, C-D\n        tracker.updateNeighbors(for: a, neighbors: [b])\n        tracker.updateNeighbors(for: b, neighbors: [a, c])\n        tracker.updateNeighbors(for: c, neighbors: [b, d])\n        tracker.updateNeighbors(for: d, neighbors: [c])\n\n        let route = try #require(tracker.computeRoute(from: a, to: d))\n        // Route should only contain intermediate hops (b, c), excluding start (a) and end (d)\n        #expect(route == [b, c])\n    }\n\n    @Test func unconfirmedEdgeDoesNotRoute() throws {\n        let tracker = MeshTopologyTracker()\n        let a = try hex(\"0101010101010101\")\n        let b = try hex(\"0202020202020202\")\n        let c = try hex(\"0303030303030303\")\n\n        // A announces B (confirmed)\n        // B announces A, C (confirmed A-B, unconfirmed B-C)\n        // C does NOT announce B\n        tracker.updateNeighbors(for: a, neighbors: [b])\n        tracker.updateNeighbors(for: b, neighbors: [a, c])\n        // C is silent or announces empty\n\n        // Should NOT find route A->C because B->C is unconfirmed (C didn't announce B)\n        #expect(tracker.computeRoute(from: a, to: c) == nil)\n        \n        // Now C announces B\n        tracker.updateNeighbors(for: c, neighbors: [b])\n        // Should find route\n        let route = try #require(tracker.computeRoute(from: a, to: c))\n        #expect(route == [b])\n    }\n\n    @Test func removingPeerClearsEdges() throws {\n        let tracker = MeshTopologyTracker()\n        let a = try hex(\"0F0E0D0C0B0A0908\")\n        let b = try hex(\"0A0B0C0D0E0F0001\")\n        let c = try hex(\"0011223344556677\")\n\n        tracker.updateNeighbors(for: a, neighbors: [b])\n        tracker.updateNeighbors(for: b, neighbors: [a, c])\n        tracker.updateNeighbors(for: c, neighbors: [b])\n\n        let initialRoute = try #require(tracker.computeRoute(from: a, to: c))\n        #expect(initialRoute == [b])\n\n        tracker.removePeer(b)\n        #expect(tracker.computeRoute(from: a, to: c) == nil)\n    }\n\n    @Test func sameStartAndEndReturnsEmptyRoute() throws {\n        let tracker = MeshTopologyTracker()\n        let a = try hex(\"0102030405060708\")\n        let b = try hex(\"1112131415161718\")\n\n        tracker.updateNeighbors(for: a, neighbors: [b])\n        tracker.updateNeighbors(for: b, neighbors: [a])\n        \n        // When start == end, route should be empty (no intermediate hops needed)\n        let route = try #require(tracker.computeRoute(from: a, to: a))\n        #expect(route == [])\n    }\n\n}\n"
  },
  {
    "path": "bitchatTests/Services/MessageRouterTests.swift",
    "content": "//\n// MessageRouterTests.swift\n// bitchatTests\n//\n// Tests for MessageRouter transport selection and outbox behavior.\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\nstruct MessageRouterTests {\n\n    @Test @MainActor\n    func sendPrivate_usesReachableTransport() async {\n        let peerID = PeerID(str: \"0000000000000001\")\n        let transportA = MockTransport()\n        let transportB = MockTransport()\n        transportB.reachablePeers.insert(peerID)\n\n        let router = MessageRouter(transports: [transportA, transportB])\n        router.sendPrivate(\"Hello\", to: peerID, recipientNickname: \"Peer\", messageID: \"m1\")\n\n        #expect(transportA.sentPrivateMessages.isEmpty)\n        #expect(transportB.sentPrivateMessages.count == 1)\n    }\n\n    @Test @MainActor\n    func sendPrivate_queuesThenFlushesWhenReachable() async {\n        let peerID = PeerID(str: \"0000000000000002\")\n        let transport = MockTransport()\n\n        let router = MessageRouter(transports: [transport])\n        router.sendPrivate(\"Queued\", to: peerID, recipientNickname: \"Peer\", messageID: \"m2\")\n\n        #expect(transport.sentPrivateMessages.isEmpty)\n\n        transport.reachablePeers.insert(peerID)\n        router.flushOutbox(for: peerID)\n\n        #expect(transport.sentPrivateMessages.count == 1)\n    }\n\n    @Test @MainActor\n    func sendReadReceipt_usesReachableTransport() async {\n        let peerID = PeerID(str: \"0000000000000003\")\n        let transport = MockTransport()\n        transport.reachablePeers.insert(peerID)\n\n        let router = MessageRouter(transports: [transport])\n        let receipt = ReadReceipt(originalMessageID: \"m3\", readerID: transport.myPeerID, readerNickname: \"Me\")\n        router.sendReadReceipt(receipt, to: peerID)\n\n        #expect(transport.sentReadReceipts.count == 1)\n    }\n\n    @Test @MainActor\n    func sendFavoriteNotification_usesConnectedOrReachable() async {\n        let peerID = PeerID(str: \"0000000000000004\")\n        let transport = MockTransport()\n        transport.reachablePeers.insert(peerID)\n\n        let router = MessageRouter(transports: [transport])\n        router.sendFavoriteNotification(to: peerID, isFavorite: true)\n\n        #expect(transport.sentFavoriteNotifications.count == 1)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/NetworkActivationServiceTests.swift",
    "content": "import Combine\nimport XCTest\n@testable import bitchat\n\n@MainActor\nfinal class NetworkActivationServiceTests: XCTestCase {\n    private let torPreferenceKey = \"networkActivationService.userTorEnabled\"\n\n    func test_start_leavesNetworkDisabledWithoutPermissionOrFavorites() {\n        let context = makeService(permission: .denied, favorites: [])\n\n        context.service.start()\n\n        XCTAssertFalse(context.service.activationAllowed)\n        XCTAssertEqual(context.torController.autoStartAllowedValues, [false])\n        XCTAssertEqual(context.proxyController.proxyModes, [false])\n        XCTAssertEqual(context.torController.startIfNeededCallCount, 0)\n        XCTAssertEqual(context.torController.shutdownCompletelyCallCount, 1)\n        XCTAssertEqual(context.relayController.connectCallCount, 0)\n        XCTAssertEqual(context.relayController.disconnectCallCount, 1)\n    }\n\n    func test_start_enablesTorAndRelaysWhenAuthorized() {\n        let context = makeService(permission: .authorized, favorites: [])\n\n        context.service.start()\n\n        XCTAssertTrue(context.service.activationAllowed)\n        XCTAssertEqual(context.torController.autoStartAllowedValues, [true])\n        XCTAssertEqual(context.proxyController.proxyModes, [true])\n        XCTAssertEqual(context.torController.startIfNeededCallCount, 1)\n        XCTAssertEqual(context.relayController.connectCallCount, 1)\n        XCTAssertEqual(context.relayController.disconnectCallCount, 0)\n    }\n\n    func test_start_respectsStoredTorPreferenceForDirectMode() {\n        let context = makeService(permission: .authorized, favorites: [])\n        context.storage.set(false, forKey: torPreferenceKey)\n\n        context.service.start()\n\n        XCTAssertTrue(context.service.activationAllowed)\n        XCTAssertFalse(context.service.userTorEnabled)\n        XCTAssertEqual(context.torController.autoStartAllowedValues, [false])\n        XCTAssertEqual(context.proxyController.proxyModes, [false])\n        XCTAssertEqual(context.torController.startIfNeededCallCount, 0)\n        XCTAssertEqual(context.torController.shutdownCompletelyCallCount, 1)\n        XCTAssertEqual(context.relayController.connectCallCount, 1)\n    }\n\n    func test_setUserTorEnabled_postsNotificationAndReconnectsOnTransportSwitch() {\n        let context = makeService(permission: .authorized, favorites: [])\n        let notified = expectation(description: \"Tor preference notification\")\n        let token = context.notificationCenter.addObserver(\n            forName: .TorUserPreferenceChanged,\n            object: nil,\n            queue: nil\n        ) { note in\n            XCTAssertEqual(note.userInfo?[\"enabled\"] as? Bool, false)\n            notified.fulfill()\n        }\n\n        context.service.start()\n        context.service.setUserTorEnabled(false)\n\n        wait(for: [notified], timeout: 1.0)\n        context.notificationCenter.removeObserver(token)\n\n        XCTAssertFalse(context.service.userTorEnabled)\n        XCTAssertEqual(context.storage.object(forKey: torPreferenceKey) as? Bool, false)\n        XCTAssertEqual(Array(context.proxyController.proxyModes.suffix(2)), [true, false])\n        XCTAssertEqual(Array(context.torController.autoStartAllowedValues.suffix(2)), [true, false])\n        XCTAssertEqual(context.relayController.disconnectCallCount, 1)\n        XCTAssertEqual(context.relayController.connectCallCount, 2)\n    }\n\n    func test_mutualFavoritesPublisher_reactivatesNetwork() async {\n        let context = makeService(permission: .denied, favorites: [])\n\n        context.service.start()\n        XCTAssertFalse(context.service.activationAllowed)\n\n        context.favoritesSubject.send([Data([0x01])])\n        let becameActive = await waitUntil { context.service.activationAllowed }\n        XCTAssertTrue(becameActive)\n\n        XCTAssertTrue(context.service.activationAllowed)\n        XCTAssertTrue(context.torController.autoStartAllowedValues.contains(true))\n        XCTAssertTrue(context.proxyController.proxyModes.contains(true))\n        XCTAssertGreaterThanOrEqual(context.torController.startIfNeededCallCount, 1)\n        XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)\n    }\n\n    private func makeService(\n        permission: LocationChannelManager.PermissionState,\n        favorites: Set<Data>\n    ) -> NetworkActivationTestContext {\n        let suiteName = \"NetworkActivationServiceTests-\\(UUID().uuidString)\"\n        let storage = UserDefaults(suiteName: suiteName)!\n        storage.removePersistentDomain(forName: suiteName)\n\n        let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)\n        let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)\n        let torController = MockNetworkActivationTorController()\n        let relayController = MockNetworkActivationRelayController()\n        let proxyController = MockNetworkActivationProxyController()\n        let notificationCenter = NotificationCenter()\n        let service = NetworkActivationService(\n            storage: storage,\n            locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),\n            mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),\n            permissionProvider: { permissionSubject.value },\n            mutualFavoritesProvider: { favoritesSubject.value },\n            torController: torController,\n            relayController: relayController,\n            proxyController: proxyController,\n            notificationCenter: notificationCenter\n        )\n        return NetworkActivationTestContext(\n            service: service,\n            storage: storage,\n            permissionSubject: permissionSubject,\n            favoritesSubject: favoritesSubject,\n            torController: torController,\n            relayController: relayController,\n            proxyController: proxyController,\n            notificationCenter: notificationCenter\n        )\n    }\n\n    private func waitUntil(\n        timeout: TimeInterval = 1.0,\n        condition: @escaping @MainActor () -> Bool\n    ) async -> Bool {\n        let deadline = Date().addingTimeInterval(timeout)\n        while Date() < deadline {\n            if condition() {\n                return true\n            }\n            try? await Task.sleep(nanoseconds: 10_000_000)\n        }\n        return condition()\n    }\n}\n\n@MainActor\nprivate struct NetworkActivationTestContext {\n    let service: NetworkActivationService\n    let storage: UserDefaults\n    let permissionSubject: CurrentValueSubject<LocationChannelManager.PermissionState, Never>\n    let favoritesSubject: CurrentValueSubject<Set<Data>, Never>\n    let torController: MockNetworkActivationTorController\n    let relayController: MockNetworkActivationRelayController\n    let proxyController: MockNetworkActivationProxyController\n    let notificationCenter: NotificationCenter\n}\n\n@MainActor\nprivate final class MockNetworkActivationTorController: NetworkActivationTorControlling {\n    private(set) var autoStartAllowedValues: [Bool] = []\n    private(set) var startIfNeededCallCount = 0\n    private(set) var shutdownCompletelyCallCount = 0\n\n    func setAutoStartAllowed(_ allowed: Bool) {\n        autoStartAllowedValues.append(allowed)\n    }\n\n    func startIfNeeded() {\n        startIfNeededCallCount += 1\n    }\n\n    func shutdownCompletely() {\n        shutdownCompletelyCallCount += 1\n    }\n}\n\n@MainActor\nprivate final class MockNetworkActivationRelayController: NetworkActivationRelayControlling {\n    private(set) var connectCallCount = 0\n    private(set) var disconnectCallCount = 0\n\n    func connect() {\n        connectCallCount += 1\n    }\n\n    func disconnect() {\n        disconnectCallCount += 1\n    }\n}\n\nprivate final class MockNetworkActivationProxyController: NetworkActivationProxyControlling {\n    private(set) var proxyModes: [Bool] = []\n\n    func setProxyMode(useTor: Bool) {\n        proxyModes.append(useTor)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/NoiseEncryptionServiceTests.swift",
    "content": "import Foundation\nimport Testing\n@testable import bitchat\n\n@Suite(\"NoiseEncryptionService Tests\")\nstruct NoiseEncryptionServiceTests {\n\n    @Test(\"Encryption status accessors cover all cases\")\n    func encryptionStatusAccessorsCoverAllCases() {\n        #expect(EncryptionStatus.none.icon == \"lock.slash\")\n        #expect(EncryptionStatus.noHandshake.icon == nil)\n        #expect(EncryptionStatus.noiseHandshaking.icon == \"lock.rotation\")\n        #expect(EncryptionStatus.noiseSecured.icon == \"lock.fill\")\n        #expect(EncryptionStatus.noiseVerified.icon == \"checkmark.seal.fill\")\n\n        #expect(!EncryptionStatus.none.description.isEmpty)\n        #expect(!EncryptionStatus.noHandshake.description.isEmpty)\n        #expect(!EncryptionStatus.noiseHandshaking.description.isEmpty)\n        #expect(!EncryptionStatus.noiseSecured.description.isEmpty)\n        #expect(!EncryptionStatus.noiseVerified.description.isEmpty)\n\n        #expect(!EncryptionStatus.none.accessibilityDescription.isEmpty)\n        #expect(!EncryptionStatus.noHandshake.accessibilityDescription.isEmpty)\n        #expect(!EncryptionStatus.noiseHandshaking.accessibilityDescription.isEmpty)\n        #expect(!EncryptionStatus.noiseSecured.accessibilityDescription.isEmpty)\n        #expect(!EncryptionStatus.noiseVerified.accessibilityDescription.isEmpty)\n    }\n\n    @Test(\"Announce and packet signatures round-trip and detect tampering\")\n    func announceAndPacketSignaturesRoundTrip() throws {\n        let service = NoiseEncryptionService(keychain: MockKeychain())\n        let signingPublicKey = service.getSigningPublicKeyData()\n        let noisePublicKey = service.getStaticPublicKeyData()\n\n        let signature = try #require(\n            service.buildAnnounceSignature(\n                peerID: Data([0xAA, 0xBB]),\n                noiseKey: noisePublicKey,\n                ed25519Key: signingPublicKey,\n                nickname: \"Alice\",\n                timestampMs: 12345\n            ),\n            \"Expected announce signature\"\n        )\n\n        #expect(\n            service.verifyAnnounceSignature(\n                signature: signature,\n                peerID: Data([0xAA, 0xBB]),\n                noiseKey: noisePublicKey,\n                ed25519Key: signingPublicKey,\n                nickname: \"Alice\",\n                timestampMs: 12345,\n                publicKey: signingPublicKey\n            )\n        )\n        #expect(\n            !service.verifyAnnounceSignature(\n                signature: signature,\n                peerID: Data([0xAA, 0xBB]),\n                noiseKey: noisePublicKey,\n                ed25519Key: signingPublicKey,\n                nickname: \"Mallory\",\n                timestampMs: 12345,\n                publicKey: signingPublicKey\n            )\n        )\n        #expect(!service.verifySignature(signature, for: Data(\"data\".utf8), publicKey: Data([1, 2, 3])))\n\n        let packet = BitchatPacket(\n            type: MessageType.announce.rawValue,\n            senderID: Data([0, 1, 2, 3, 4, 5, 6, 7]),\n            recipientID: nil,\n            timestamp: 42,\n            payload: Data(\"payload\".utf8),\n            signature: nil,\n            ttl: 7\n        )\n        let signedPacket = try #require(service.signPacket(packet), \"Expected signed packet\")\n\n        #expect(service.verifyPacketSignature(signedPacket, publicKey: signingPublicKey))\n        #expect(!service.verifyPacketSignature(packet, publicKey: signingPublicKey))\n\n        var tampered = signedPacket\n        tampered.signature = Data(repeating: 0xFF, count: 64)\n        #expect(!service.verifyPacketSignature(tampered, publicKey: signingPublicKey))\n    }\n\n    @Test(\"Service-level handshake, encryption, and fingerprint lifecycle work\")\n    func handshakeEncryptionAndFingerprintLifecycle() async throws {\n        let alice = NoiseEncryptionService(keychain: MockKeychain())\n        let bob = NoiseEncryptionService(keychain: MockKeychain())\n        let alicePeerID = PeerID(str: \"0011223344556677\")\n        let bobPeerID = PeerID(str: \"8899aabbccddeeff\")\n        let recorder = AuthenticationRecorder()\n\n        #expect(alice.onPeerAuthenticated == nil)\n        alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))\n        bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)\n\n        try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID)\n\n        let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 0.5)\n        #expect(authenticated)\n        #expect(alice.hasEstablishedSession(with: alicePeerID))\n        #expect(bob.hasEstablishedSession(with: bobPeerID))\n        #expect(alice.hasSession(with: alicePeerID))\n        #expect(bob.hasSession(with: bobPeerID))\n        #expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32)\n        #expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32)\n        #expect(alice.getPeerFingerprint(alicePeerID) != nil)\n        #expect(bob.getPeerFingerprint(bobPeerID) != nil)\n\n        let plaintext = Data(\"secret payload\".utf8)\n        let ciphertext = try alice.encrypt(plaintext, for: alicePeerID)\n        let decrypted = try bob.decrypt(ciphertext, from: bobPeerID)\n        #expect(decrypted == plaintext)\n\n        alice.clearSession(for: alicePeerID)\n        #expect(!alice.hasSession(with: alicePeerID))\n        #expect(alice.getPeerFingerprint(alicePeerID) == nil)\n\n        bob.clearEphemeralStateForPanic()\n        #expect(!bob.hasSession(with: bobPeerID))\n        #expect(bob.getPeerFingerprint(bobPeerID) == nil)\n    }\n\n    @Test(\"Encrypt without a session requests handshake and decrypt without session fails\")\n    func handshakeRequiredAndSessionNotEstablishedErrors() throws {\n        let service = NoiseEncryptionService(keychain: MockKeychain())\n        let peerID = PeerID(str: \"1021324354657687\")\n        var requestedPeerID: PeerID?\n\n        service.onHandshakeRequired = { requestedPeerID = $0 }\n\n        do {\n            _ = try service.encrypt(Data(\"hello\".utf8), for: peerID)\n            Issue.record(\"Expected handshakeRequired error\")\n        } catch NoiseEncryptionError.handshakeRequired {\n            #expect(requestedPeerID == peerID)\n        } catch {\n            Issue.record(\"Unexpected error: \\(error)\")\n        }\n\n        do {\n            _ = try service.decrypt(Data(\"hello\".utf8), from: peerID)\n            Issue.record(\"Expected sessionNotEstablished error\")\n        } catch NoiseEncryptionError.sessionNotEstablished {\n            // Expected\n        } catch {\n            Issue.record(\"Unexpected error: \\(error)\")\n        }\n    }\n\n    @Test(\"Clearing persistent identity removes saved keys\")\n    func clearPersistentIdentityRemovesSavedKeys() {\n        let keychain = MockKeychain()\n        let service = NoiseEncryptionService(keychain: keychain)\n\n        #expect(service.getStaticPublicKeyData().count == 32)\n        #expect(service.getSigningPublicKeyData().count == 32)\n\n        service.clearPersistentIdentity()\n\n        if case .itemNotFound = keychain.getIdentityKeyWithResult(forKey: \"noiseStaticKey\") {\n        } else {\n            Issue.record(\"Expected noiseStaticKey to be removed\")\n        }\n\n        if case .itemNotFound = keychain.getIdentityKeyWithResult(forKey: \"ed25519SigningKey\") {\n        } else {\n            Issue.record(\"Expected ed25519SigningKey to be removed\")\n        }\n    }\n\n    @Test(\"NoiseMessage JSON and binary encoding round-trip\")\n    func noiseMessageRoundTrips() throws {\n        let message = NoiseMessage(\n            type: .encryptedMessage,\n            sessionID: UUID().uuidString,\n            payload: Data([1, 2, 3, 4])\n        )\n\n        let encoded = try #require(message.encode(), \"Expected JSON encoding\")\n        let decoded = try #require(NoiseMessage.decode(from: encoded), \"Expected JSON decode\")\n        #expect(decoded.type == message.type)\n        #expect(decoded.sessionID == message.sessionID)\n        #expect(decoded.payload == message.payload)\n\n        #expect(NoiseMessage.decodeWithError(from: Data(\"bad\".utf8)) == nil)\n\n        let binary = message.toBinaryData()\n        let roundTripped = try #require(NoiseMessage.fromBinaryData(binary), \"Expected binary decode\")\n        #expect(roundTripped.type == message.type)\n        #expect(roundTripped.sessionID == message.sessionID)\n        #expect(roundTripped.payload == message.payload)\n        #expect(NoiseMessage.fromBinaryData(Data()) == nil)\n    }\n\n    private func establishSessions(\n        alice: NoiseEncryptionService,\n        bob: NoiseEncryptionService,\n        alicePeerID: PeerID,\n        bobPeerID: PeerID\n    ) throws {\n        let message1 = try alice.initiateHandshake(with: alicePeerID)\n        let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1)\n        let message2 = try #require(response, \"Expected handshake response\")\n        let final = try alice.processHandshakeMessage(from: alicePeerID, message: message2)\n        let message3 = try #require(final, \"Expected handshake final\")\n        let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3)\n        #expect(finalMessage == nil)\n    }\n}\n\nprivate final class AuthenticationRecorder: @unchecked Sendable {\n    private let lock = NSLock()\n    private var entries: [(PeerID, String)] = []\n\n    var count: Int {\n        lock.lock()\n        defer { lock.unlock() }\n        return entries.count\n    }\n\n    func record(peerID: PeerID, fingerprint: String) {\n        lock.lock()\n        entries.append((peerID, fingerprint))\n        lock.unlock()\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/NostrRelayManagerTests.swift",
    "content": "import Combine\nimport XCTest\n@testable import bitchat\n\n@MainActor\nfinal class NostrRelayManagerTests: XCTestCase {\n    func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async {\n        let context = makeContext(permission: .authorized, activationAllowed: false)\n\n        XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)\n        context.activationAllowed.value = true\n\n        context.manager.connect()\n\n        let connected = await waitUntil {\n            context.sessionFactory.requestedURLs.count == 5 &&\n            context.manager.relays.allSatisfy(\\.isConnected)\n        }\n        XCTAssertTrue(connected)\n    }\n\n    func test_permissionPublisher_addsAndRemovesDefaultRelays() async {\n        let context = makeContext(permission: .denied, favorites: [])\n\n        XCTAssertEqual(context.manager.getRelayStatuses().count, 0)\n\n        context.permissionSubject.send(.authorized)\n\n        let defaultRelaysConnected = await waitUntil {\n            context.manager.getRelayStatuses().count == 5 &&\n            context.manager.relays.allSatisfy(\\.isConnected)\n        }\n        XCTAssertTrue(defaultRelaysConnected)\n\n        context.permissionSubject.send(.denied)\n\n        let defaultRelaysRemoved = await waitUntil {\n            context.manager.getRelayStatuses().isEmpty\n        }\n        XCTAssertTrue(defaultRelaysRemoved)\n        XCTAssertEqual(context.sessionFactory.allConnections.count, 5)\n        XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 })\n    }\n\n    func test_connect_waitsForTorReadinessBeforeCreatingSessions() async {\n        let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)\n\n        context.manager.connect()\n\n        XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)\n\n        context.torWaiter.resolve(true)\n\n        let connectedAfterTorReady = await waitUntil {\n            context.sessionFactory.requestedURLs.count == 5 &&\n            context.manager.relays.allSatisfy(\\.isConnected)\n        }\n        XCTAssertTrue(connectedAfterTorReady)\n    }\n\n    func test_connect_whenTorReadinessFailsDoesNotCreateSessions() async {\n        let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)\n\n        context.manager.connect()\n        context.torWaiter.resolve(false)\n        try? await Task.sleep(nanoseconds: 20_000_000)\n\n        XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)\n        XCTAssertFalse(context.manager.isConnected)\n    }\n\n    func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {\n        let relayURL = \"wss://tor-ready.example\"\n        let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)\n        let event = try makeSignedEvent(content: \"deferred\")\n\n        context.manager.sendEvent(event, to: [relayURL])\n\n        XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)\n\n        context.torWaiter.resolve(true)\n\n        let sentAfterTorReady = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1\n        }\n        XCTAssertTrue(sentAfterTorReady)\n    }\n\n    func test_sendEvent_queuesWhileBackgroundedAndFlushesWhenForegrounded() async throws {\n        let relayURL = \"wss://queue-flush.example\"\n        let context = makeContext(\n            permission: .denied,\n            userTorEnabled: true,\n            torEnforced: true,\n            torIsReady: true,\n            torIsForeground: false\n        )\n        let event = try makeSignedEvent(content: \"queued\")\n\n        context.manager.sendEvent(event, to: [relayURL])\n        try? await Task.sleep(nanoseconds: 20_000_000)\n\n        XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)\n        context.torForeground.value = true\n        context.manager.ensureConnections(to: [relayURL])\n\n        let flushed = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1\n        }\n        XCTAssertTrue(flushed)\n    }\n\n    func test_sendEvent_sendFailureDoesNotIncrementMessageCount() async throws {\n        let relayURL = \"wss://send-failure.example\"\n        let context = makeContext(permission: .denied)\n        context.sessionFactory.sendErrorByURL[relayURL] = NSError(domain: \"send\", code: 1)\n        let event = try makeSignedEvent(content: \"send failure\")\n\n        context.manager.sendEvent(event, to: [relayURL])\n\n        let attempted = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1\n        }\n        XCTAssertTrue(attempted)\n\n        try? await Task.sleep(nanoseconds: 20_000_000)\n        XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent, 0)\n    }\n\n    func test_sendEvent_queueIsPrunedWhenDefaultRelaysAreRevoked() async throws {\n        let context = makeContext(\n            permission: .authorized,\n            userTorEnabled: true,\n            torEnforced: true,\n            torIsReady: true,\n            torIsForeground: false\n        )\n        let event = try makeSignedEvent(content: \"queued default\")\n\n        context.manager.sendEvent(event)\n\n        let queued = await waitUntil {\n            context.manager.debugPendingMessageQueueCount == 1\n        }\n        XCTAssertTrue(queued)\n\n        context.permissionSubject.send(.denied)\n\n        let cleared = await waitUntil {\n            context.manager.debugPendingMessageQueueCount == 0 &&\n            context.manager.relays.isEmpty\n        }\n        XCTAssertTrue(cleared)\n    }\n\n    func test_connect_doesNothingWhenActivationIsDisallowed() {\n        let context = makeContext(permission: .authorized, activationAllowed: false)\n\n        context.manager.connect()\n\n        XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)\n        XCTAssertFalse(context.manager.isConnected)\n    }\n\n    func test_ensureConnections_deduplicatesRelayURLs() async {\n        let relayOne = \"wss://relay-one.example\"\n        let relayTwo = \"wss://relay-two.example\"\n        let context = makeContext(permission: .denied)\n\n        context.manager.ensureConnections(to: [relayOne, relayOne, relayTwo])\n\n        let connected = await waitUntil {\n            Set(context.manager.getRelayStatuses().map(\\.url)) == Set([relayOne, relayTwo]) &&\n            context.manager.relays.allSatisfy(\\.isConnected)\n        }\n        XCTAssertTrue(connected)\n        XCTAssertEqual(context.sessionFactory.requestedURLs, [relayOne, relayTwo])\n    }\n\n    func test_subscribe_coalescesRapidDuplicateRequests() async {\n        let relayURL = \"wss://subscribe.example\"\n        let context = makeContext(permission: .denied)\n        let filter = makeFilter()\n\n        context.manager.subscribe(filter: filter, id: \"sub\", relayUrls: [relayURL], handler: { _ in })\n\n        let firstSent = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1\n        }\n        XCTAssertTrue(firstSent)\n\n        context.clock.now = context.clock.now.addingTimeInterval(0.5)\n        context.manager.subscribe(filter: filter, id: \"sub\", relayUrls: [relayURL], handler: { _ in })\n\n        XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)\n    }\n\n    func test_subscribe_waitsForTorReadinessAndPreservesEOSECallback() async throws {\n        let relayURL = \"wss://tor-subscribe.example\"\n        let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)\n        var eoseCount = 0\n\n        context.manager.subscribe(\n            filter: makeFilter(),\n            id: \"tor-eose\",\n            relayUrls: [relayURL],\n            handler: { _ in },\n            onEOSE: { eoseCount += 1 }\n        )\n\n        XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)\n\n        context.torWaiter.resolve(true)\n        let subscribed = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1\n        }\n        XCTAssertTrue(subscribed)\n\n        try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: \"tor-eose\")\n        let eoseCompleted = await waitUntil { eoseCount == 1 }\n        XCTAssertTrue(eoseCompleted)\n    }\n\n    func test_subscribe_withoutAllowedRelays_callsEOSEImmediatelyAndDoesNotFlushLater() async {\n        let context = makeContext(permission: .denied)\n        var eoseCount = 0\n\n        context.manager.subscribe(\n            filter: makeFilter(),\n            id: \"blocked-defaults\",\n            handler: { _ in },\n            onEOSE: { eoseCount += 1 }\n        )\n\n        XCTAssertEqual(eoseCount, 1)\n        XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)\n\n        context.permissionSubject.send(.authorized)\n        let connected = await waitUntil {\n            context.sessionFactory.allConnections.count == 5 &&\n            context.manager.relays.allSatisfy(\\.isConnected)\n        }\n        XCTAssertTrue(connected)\n        XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.sentStrings.isEmpty })\n    }\n\n    func test_permissionRevocation_clearsQueuedDefaultSubscriptions() async {\n        let context = makeContext(\n            permission: .authorized,\n            userTorEnabled: true,\n            torEnforced: true,\n            torIsReady: true,\n            torIsForeground: false\n        )\n        let defaultRelay = \"wss://relay.damus.io\"\n\n        context.manager.subscribe(filter: makeFilter(), id: \"queued-default\", handler: { _ in })\n\n        let queued = await waitUntil {\n            context.manager.debugPendingSubscriptionCount(for: defaultRelay) == 1\n        }\n        XCTAssertTrue(queued)\n\n        context.permissionSubject.send(.denied)\n\n        let cleared = await waitUntil {\n            context.manager.debugPendingSubscriptionCount(for: defaultRelay) == 0 &&\n            context.manager.relays.isEmpty\n        }\n        XCTAssertTrue(cleared)\n    }\n\n    func test_unsubscribe_allowsResubscribeWithSameID() async {\n        let relayURL = \"wss://subscribe.example\"\n        let context = makeContext(permission: .denied)\n        let filter = makeFilter()\n\n        context.manager.subscribe(filter: filter, id: \"sub\", relayUrls: [relayURL], handler: { _ in })\n        let initialSubscribeSent = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1\n        }\n        XCTAssertTrue(initialSubscribeSent)\n\n        context.manager.unsubscribe(id: \"sub\")\n        let closeSent = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 2\n        }\n        XCTAssertTrue(closeSent)\n\n        context.clock.now = context.clock.now.addingTimeInterval(0.2)\n        context.manager.subscribe(filter: filter, id: \"sub\", relayUrls: [relayURL], handler: { _ in })\n\n        let resubscribed = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 3\n        }\n        XCTAssertTrue(resubscribed)\n    }\n\n    func test_receiveEvent_deliversHandlerAndTracksReceivedCount() async throws {\n        let relayURL = \"wss://events.example\"\n        let context = makeContext(permission: .denied)\n        let filter = makeFilter()\n        let event = try makeSignedEvent(content: \"hello\")\n        var receivedEvent: NostrEvent?\n\n        context.manager.subscribe(filter: filter, id: \"events\", relayUrls: [relayURL]) { event in\n            receivedEvent = event\n        }\n        let subscriptionSent = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1\n        }\n        XCTAssertTrue(subscriptionSent)\n\n        try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: \"events\", event: event)\n\n        let delivered = await waitUntil {\n            receivedEvent?.id == event.id &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1\n        }\n        XCTAssertTrue(delivered)\n        XCTAssertEqual(receivedEvent?.id, event.id)\n    }\n\n    func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {\n        let relayURL = \"wss://missing-handler.example\"\n        let context = makeContext(permission: .denied)\n        let event = try makeSignedEvent(content: \"unhandled\")\n\n        context.manager.ensureConnections(to: [relayURL])\n        let connected = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL) != nil &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true\n        }\n        XCTAssertTrue(connected)\n\n        try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: \"missing\", event: event)\n\n        let counted = await waitUntil {\n            context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1\n        }\n        XCTAssertTrue(counted)\n    }\n\n    func test_noticeAndMalformedMessages_keepReceiveLoopAliveForLaterEvents() async throws {\n        let relayURL = \"wss://parser.example\"\n        let context = makeContext(permission: .denied)\n        var receivedIDs: [String] = []\n        let firstEvent = try makeSignedEvent(content: \"after notice\")\n        let secondEvent = try makeSignedEvent(content: \"after malformed\")\n\n        context.manager.subscribe(filter: makeFilter(), id: \"parser\", relayUrls: [relayURL]) { event in\n            receivedIDs.append(event.id)\n        }\n        let subscribed = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1\n        }\n        XCTAssertTrue(subscribed)\n\n        try context.sessionFactory.latestConnection(for: relayURL)?.emitNotice(message: \"ignored\")\n        try? await Task.sleep(nanoseconds: 20_000_000)\n        try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: \"parser\", event: firstEvent)\n\n        let firstDelivered = await waitUntil {\n            receivedIDs == [firstEvent.id]\n        }\n        XCTAssertTrue(firstDelivered)\n\n        try context.sessionFactory.latestConnection(for: relayURL)?.emitRawString(\"not-json\")\n        try? await Task.sleep(nanoseconds: 20_000_000)\n        try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: \"parser\", event: secondEvent)\n\n        let secondDelivered = await waitUntil {\n            receivedIDs == [firstEvent.id, secondEvent.id]\n        }\n        XCTAssertTrue(secondDelivered)\n    }\n\n    func test_okMessages_clearPendingGiftWrapIDs() async throws {\n        let relayURL = \"wss://ok.example\"\n        let context = makeContext(permission: .denied)\n        let successID = \"gift-wrap-success\"\n        let failureID = \"gift-wrap-failure\"\n\n        context.manager.ensureConnections(to: [relayURL])\n        let connected = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL) != nil &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true\n        }\n        XCTAssertTrue(connected)\n\n        NostrRelayManager.registerPendingGiftWrap(id: successID)\n        try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: \"ok\")\n        let successCleared = await waitUntil {\n            !NostrRelayManager.pendingGiftWrapIDs.contains(successID)\n        }\n        XCTAssertTrue(successCleared)\n\n        NostrRelayManager.registerPendingGiftWrap(id: failureID)\n        try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: \"rejected\")\n        let failureCleared = await waitUntil {\n            !NostrRelayManager.pendingGiftWrapIDs.contains(failureID)\n        }\n        XCTAssertTrue(failureCleared)\n    }\n\n    func test_eoseCallback_waitsForAllTargetedRelays() async throws {\n        let relayOne = \"wss://one.example\"\n        let relayTwo = \"wss://two.example\"\n        let context = makeContext(permission: .denied)\n        var eoseCount = 0\n\n        context.manager.subscribe(\n            filter: makeFilter(),\n            id: \"eose\",\n            relayUrls: [relayOne, relayTwo],\n            handler: { _ in },\n            onEOSE: { eoseCount += 1 }\n        )\n\n        let bothConnected = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 &&\n            context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1\n        }\n        XCTAssertTrue(bothConnected)\n\n        try context.sessionFactory.latestConnection(for: relayOne)?.emitEOSE(subscriptionID: \"eose\")\n        try? await Task.sleep(nanoseconds: 20_000_000)\n        XCTAssertEqual(eoseCount, 0)\n\n        try context.sessionFactory.latestConnection(for: relayTwo)?.emitEOSE(subscriptionID: \"eose\")\n\n        let eoseCompleted = await waitUntil { eoseCount == 1 }\n        XCTAssertTrue(eoseCompleted)\n    }\n\n    func test_eoseTimeout_invokesCallbackOnceAndIgnoresLateEOSE() async throws {\n        let relayURL = \"wss://timeout.example\"\n        let context = makeContext(permission: .denied)\n        var eoseCount = 0\n\n        context.manager.subscribe(\n            filter: makeFilter(),\n            id: \"timeout\",\n            relayUrls: [relayURL],\n            handler: { _ in },\n            onEOSE: { eoseCount += 1 }\n        )\n\n        let subscribed = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1\n        }\n        XCTAssertTrue(subscribed)\n\n        let timedOut = await waitUntil(timeout: 3.0) { eoseCount == 1 }\n        XCTAssertTrue(timedOut)\n\n        try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: \"timeout\")\n        try? await Task.sleep(nanoseconds: 20_000_000)\n        XCTAssertEqual(eoseCount, 1)\n    }\n\n    func test_receiveFailure_schedulesReconnectWithBackoff() async {\n        let relayURL = \"wss://retry.example\"\n        let context = makeContext(permission: .denied)\n\n        context.manager.ensureConnections(to: [relayURL])\n        let firstConnected = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL) != nil\n        }\n        XCTAssertTrue(firstConnected)\n\n        let firstConnection = context.sessionFactory.latestConnection(for: relayURL)\n        firstConnection?.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))\n\n        let retryScheduled = await waitUntil {\n            context.scheduler.scheduled.count == 1 &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 1\n        }\n        XCTAssertTrue(retryScheduled)\n        XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrRelayInitialBackoffSeconds)\n\n        let initialRequestCount = context.sessionFactory.requestedURLs.count\n        context.scheduler.runNext()\n\n        let retried = await waitUntil {\n            context.sessionFactory.requestedURLs.count == initialRequestCount + 1\n        }\n        XCTAssertTrue(retried)\n    }\n\n    func test_receiveFailure_whenActivationBecomesDisallowedDoesNotScheduleReconnect() async {\n        let relayURL = \"wss://no-retry.example\"\n        let context = makeContext(permission: .denied)\n\n        context.manager.ensureConnections(to: [relayURL])\n        let connected = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL) != nil &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true\n        }\n        XCTAssertTrue(connected)\n\n        context.activationAllowed.value = false\n        context.sessionFactory.latestConnection(for: relayURL)?.fail(\n            error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)\n        )\n\n        let disconnected = await waitUntil {\n            context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == false\n        }\n        XCTAssertTrue(disconnected)\n        XCTAssertTrue(context.scheduler.scheduled.isEmpty)\n        XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)\n    }\n\n    func test_disconnect_invalidatesScheduledReconnectGeneration() async {\n        let relayURL = \"wss://disconnect.example\"\n        let context = makeContext(permission: .denied)\n\n        context.manager.ensureConnections(to: [relayURL])\n        let firstConnected = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL) != nil\n        }\n        XCTAssertTrue(firstConnected)\n\n        context.sessionFactory.latestConnection(for: relayURL)?.fail(\n            error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)\n        )\n        let retryScheduled = await waitUntil { context.scheduler.scheduled.count == 1 }\n        XCTAssertTrue(retryScheduled)\n\n        let requestCountBeforeDisconnect = context.sessionFactory.requestedURLs.count\n        context.manager.disconnect()\n        context.scheduler.runNext()\n        try? await Task.sleep(nanoseconds: 20_000_000)\n\n        XCTAssertEqual(context.sessionFactory.requestedURLs.count, requestCountBeforeDisconnect)\n    }\n\n    func test_retryConnection_cancelsActiveConnectionBeforeReconnecting() async {\n        let relayURL = \"wss://retry-now.example\"\n        let context = makeContext(permission: .denied)\n\n        context.manager.ensureConnections(to: [relayURL])\n        let connected = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL) != nil &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true\n        }\n        XCTAssertTrue(connected)\n\n        guard let firstConnection = context.sessionFactory.latestConnection(for: relayURL) else {\n            XCTFail(\"Expected initial connection\")\n            return\n        }\n        let initialRequestCount = context.sessionFactory.requestedURLs.count\n\n        context.manager.retryConnection(to: relayURL)\n\n        let reconnected = await waitUntil {\n            guard let latest = context.sessionFactory.latestConnection(for: relayURL) else { return false }\n            return context.sessionFactory.requestedURLs.count == initialRequestCount + 1 &&\n                latest !== firstConnection\n        }\n        XCTAssertTrue(reconnected)\n        XCTAssertEqual(firstConnection.cancelCallCount, 1)\n    }\n\n    func test_retryConnection_whenTorReadinessFailsDoesNotReconnect() async {\n        let relayURL = \"wss://retry-tor.example\"\n        let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true)\n\n        context.manager.ensureConnections(to: [relayURL])\n        let connected = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL) != nil &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true\n        }\n        XCTAssertTrue(connected)\n\n        guard let firstConnection = context.sessionFactory.latestConnection(for: relayURL) else {\n            XCTFail(\"Expected initial connection\")\n            return\n        }\n\n        let initialRequestCount = context.sessionFactory.requestedURLs.count\n        context.torWaiter.isReady = false\n        context.manager.retryConnection(to: relayURL)\n\n        XCTAssertEqual(firstConnection.cancelCallCount, 1)\n        XCTAssertEqual(context.sessionFactory.requestedURLs.count, initialRequestCount)\n\n        context.torWaiter.resolve(false)\n        try? await Task.sleep(nanoseconds: 20_000_000)\n\n        XCTAssertEqual(context.sessionFactory.requestedURLs.count, initialRequestCount)\n    }\n\n    func test_resetAllConnections_clearsRelayStateAndReconnects() async {\n        let relayURL = \"wss://reset.example\"\n        let context = makeContext(permission: .denied)\n\n        context.manager.ensureConnections(to: [relayURL])\n        let connected = await waitUntil {\n            context.sessionFactory.latestConnection(for: relayURL) != nil &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true\n        }\n        XCTAssertTrue(connected)\n\n        context.sessionFactory.latestConnection(for: relayURL)?.fail(\n            error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)\n        )\n        let failed = await waitUntil {\n            context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 1 &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.lastError != nil\n        }\n        XCTAssertTrue(failed)\n\n        let requestCountBeforeReset = context.sessionFactory.requestedURLs.count\n        context.manager.resetAllConnections()\n\n        let reset = await waitUntil {\n            context.sessionFactory.requestedURLs.count == requestCountBeforeReset + 1 &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 0 &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.nextReconnectTime == nil &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.lastError == nil\n        }\n        XCTAssertTrue(reset)\n    }\n\n    func test_debugFlushMessageQueue_flushesAllConnectedRelays() async throws {\n        let relayOne = \"wss://flush-one.example\"\n        let relayTwo = \"wss://flush-two.example\"\n        let context = makeContext(\n            permission: .denied,\n            userTorEnabled: true,\n            torEnforced: true,\n            torIsReady: true,\n            torIsForeground: false\n        )\n        let event = try makeSignedEvent(content: \"flush-all\")\n\n        context.manager.sendEvent(event, to: [relayOne, relayTwo])\n        let queued = await waitUntil {\n            context.manager.debugPendingMessageQueueCount == 1\n        }\n        XCTAssertTrue(queued)\n\n        context.torForeground.value = true\n        context.manager.ensureConnections(to: [relayOne, relayTwo])\n        context.manager.debugFlushMessageQueue()\n\n        let flushed = await waitUntil {\n            context.manager.debugPendingMessageQueueCount == 0 &&\n            context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 &&\n            context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1\n        }\n        XCTAssertTrue(flushed)\n    }\n\n    func test_dnsPingFailure_marksRelayPermanentCallsEOSEImmediatelyAndManualRetryReconnects() async {\n        let relayURL = \"wss://dns-failure.example\"\n        let context = makeContext(permission: .denied)\n        context.sessionFactory.pingErrorByURL[relayURL] = NSError(\n            domain: NSURLErrorDomain,\n            code: NSURLErrorCannotFindHost,\n            userInfo: [NSLocalizedDescriptionKey: \"DNS failure\"]\n        )\n\n        context.manager.subscribe(filter: makeFilter(), id: \"dns-sub\", relayUrls: [relayURL], handler: { _ in })\n        let permanentlyFailed = await waitUntil {\n            context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == TransportConfig.nostrRelayMaxReconnectAttempts &&\n            context.scheduler.scheduled.isEmpty\n        }\n        XCTAssertTrue(permanentlyFailed)\n\n        var immediateEOSE = 0\n        context.manager.subscribe(\n            filter: makeFilter(),\n            id: \"dns-eose\",\n            relayUrls: [relayURL],\n            handler: { _ in },\n            onEOSE: { immediateEOSE += 1 }\n        )\n        XCTAssertEqual(immediateEOSE, 1)\n\n        context.sessionFactory.pingErrorByURL[relayURL] = nil\n        let requestCountBeforeRetry = context.sessionFactory.requestedURLs.count\n        context.manager.retryConnection(to: relayURL)\n\n        let reconnected = await waitUntil {\n            context.sessionFactory.requestedURLs.count == requestCountBeforeRetry + 1 &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true &&\n            context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 0\n        }\n        XCTAssertTrue(reconnected)\n    }\n\n    private func makeContext(\n        permission: LocationChannelManager.PermissionState,\n        favorites: Set<Data> = [],\n        activationAllowed: Bool = true,\n        userTorEnabled: Bool = false,\n        torEnforced: Bool = false,\n        torIsReady: Bool = true,\n        torIsForeground: Bool = true\n    ) -> RelayManagerTestContext {\n        let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)\n        let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)\n        let sessionFactory = MockRelaySessionFactory()\n        let scheduler = MockRelayScheduler()\n        let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))\n        let torWaiter = MockTorWaiter(isReady: torIsReady)\n        let torForeground = MutableBool(value: torIsForeground)\n        let activationFlag = MutableBool(value: activationAllowed)\n        let manager = NostrRelayManager(\n            dependencies: NostrRelayManagerDependencies(\n                activationAllowed: { activationFlag.value },\n                userTorEnabled: { userTorEnabled },\n                hasMutualFavorites: { !favoritesSubject.value.isEmpty },\n                hasLocationPermission: { permissionSubject.value == .authorized },\n                mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),\n                locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),\n                torEnforced: { torEnforced },\n                torIsReady: { torWaiter.isReady },\n                torIsForeground: { torForeground.value },\n                awaitTorReady: torWaiter.await(completion:),\n                makeSession: { sessionFactory },\n                scheduleAfter: { delay, action in\n                    scheduler.schedule(delay: delay, action: action)\n                },\n                now: { clock.now }\n            )\n        )\n        return RelayManagerTestContext(\n            manager: manager,\n            permissionSubject: permissionSubject,\n            favoritesSubject: favoritesSubject,\n            sessionFactory: sessionFactory,\n            scheduler: scheduler,\n            clock: clock,\n            activationAllowed: activationFlag,\n            torWaiter: torWaiter,\n            torForeground: torForeground\n        )\n    }\n\n    private func makeFilter() -> NostrFilter {\n        var filter = NostrFilter()\n        filter.kinds = [NostrProtocol.EventKind.textNote.rawValue]\n        filter.limit = 10\n        return filter\n    }\n\n    private func makeSignedEvent(content: String) throws -> NostrEvent {\n        let identity = try NostrIdentity.generate()\n        let event = NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .textNote,\n            tags: [],\n            content: content\n        )\n        return try event.sign(with: identity.schnorrSigningKey())\n    }\n\n    private func waitUntil(\n        timeout: TimeInterval = 1.0,\n        condition: @escaping @MainActor () -> Bool\n    ) async -> Bool {\n        let deadline = Date().addingTimeInterval(timeout)\n        while Date() < deadline {\n            if condition() {\n                return true\n            }\n            try? await Task.sleep(nanoseconds: 10_000_000)\n        }\n        return condition()\n    }\n}\n\n@MainActor\nprivate struct RelayManagerTestContext {\n    let manager: NostrRelayManager\n    let permissionSubject: CurrentValueSubject<LocationChannelManager.PermissionState, Never>\n    let favoritesSubject: CurrentValueSubject<Set<Data>, Never>\n    let sessionFactory: MockRelaySessionFactory\n    let scheduler: MockRelayScheduler\n    let clock: MutableClock\n    let activationAllowed: MutableBool\n    let torWaiter: MockTorWaiter\n    let torForeground: MutableBool\n}\n\nprivate final class MutableClock {\n    var now: Date\n\n    init(now: Date) {\n        self.now = now\n    }\n}\n\nprivate final class MutableBool {\n    var value: Bool\n\n    init(value: Bool) {\n        self.value = value\n    }\n}\n\nprivate final class MockTorWaiter {\n    private var completions: [(Bool) -> Void] = []\n    var isReady: Bool\n\n    init(isReady: Bool) {\n        self.isReady = isReady\n    }\n\n    func await(completion: @escaping (Bool) -> Void) {\n        completions.append(completion)\n    }\n\n    func resolve(_ ready: Bool) {\n        isReady = ready\n        let pending = completions\n        completions.removeAll()\n        pending.forEach { $0(ready) }\n    }\n}\n\nprivate final class MockRelayScheduler: @unchecked Sendable {\n    struct ScheduledAction {\n        let delay: TimeInterval\n        let action: @Sendable () -> Void\n    }\n\n    private(set) var scheduled: [ScheduledAction] = []\n\n    func schedule(delay: TimeInterval, action: @escaping @Sendable () -> Void) {\n        scheduled.append(ScheduledAction(delay: delay, action: action))\n    }\n\n    func runNext() {\n        guard !scheduled.isEmpty else { return }\n        let next = scheduled.removeFirst()\n        next.action()\n    }\n}\n\nprivate final class MockRelaySessionFactory: NostrRelaySessionProtocol {\n    private(set) var requestedURLs: [String] = []\n    private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:]\n    var pingErrorByURL: [String: Error?] = [:]\n    var sendErrorByURL: [String: Error?] = [:]\n\n    var allConnections: [MockRelayConnection] {\n        connectionsByURL.values.flatMap { $0 }\n    }\n\n    func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {\n        requestedURLs.append(url.absoluteString)\n        let connection = MockRelayConnection(\n            url: url.absoluteString,\n            pingError: pingErrorByURL[url.absoluteString] ?? nil,\n            sendError: sendErrorByURL[url.absoluteString] ?? nil\n        )\n        connectionsByURL[url.absoluteString, default: []].append(connection)\n        return connection\n    }\n\n    func latestConnection(for url: String) -> MockRelayConnection? {\n        connectionsByURL[url]?.last\n    }\n}\n\nprivate final class MockRelayConnection: NostrRelayConnectionProtocol {\n    private let url: String\n    private let pingError: Error?\n    private let sendError: Error?\n    private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?\n    private(set) var resumeCallCount = 0\n    private(set) var cancelCallCount = 0\n    private(set) var sentMessages: [URLSessionWebSocketTask.Message] = []\n\n    var sentStrings: [String] {\n        sentMessages.compactMap {\n            switch $0 {\n            case .string(let string): string\n            case .data(let data): String(data: data, encoding: .utf8)\n            @unknown default: nil\n            }\n        }\n    }\n\n    init(url: String, pingError: Error? = nil, sendError: Error? = nil) {\n        self.url = url\n        self.pingError = pingError\n        self.sendError = sendError\n    }\n\n    func resume() {\n        resumeCallCount += 1\n    }\n\n    func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {\n        cancelCallCount += 1\n    }\n\n    func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {\n        sentMessages.append(message)\n        completionHandler(sendError)\n    }\n\n    func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {\n        receiveHandler = completionHandler\n    }\n\n    func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {\n        pongReceiveHandler(pingError)\n    }\n\n    func fail(error: Error) {\n        let handler = receiveHandler\n        receiveHandler = nil\n        handler?(.failure(error))\n    }\n\n    func emitEventMessage(subscriptionID: String, event: NostrEvent) throws {\n        let eventData = try JSONEncoder().encode(event)\n        let eventJSONObject = try JSONSerialization.jsonObject(with: eventData) as! [String: Any]\n        let payload: [Any] = [\"EVENT\", subscriptionID, eventJSONObject]\n        try emit(jsonObject: payload)\n    }\n\n    func emitEOSE(subscriptionID: String) throws {\n        try emit(jsonObject: [\"EOSE\", subscriptionID])\n    }\n\n    func emitOK(eventID: String, success: Bool, reason: String) throws {\n        try emit(jsonObject: [\"OK\", eventID, success, reason])\n    }\n\n    func emitNotice(message: String) throws {\n        try emit(jsonObject: [\"NOTICE\", message])\n    }\n\n    func emitRawString(_ string: String) throws {\n        let handler = receiveHandler\n        receiveHandler = nil\n        handler?(.success(.string(string)))\n    }\n\n    private func emit(jsonObject: Any) throws {\n        let data = try JSONSerialization.data(withJSONObject: jsonObject)\n        let handler = receiveHandler\n        receiveHandler = nil\n        handler?(.success(.data(data)))\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/NostrTransportTests.swift",
    "content": "//\n// NostrTransportTests.swift\n// bitchat\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\nimport Testing\n@testable import bitchat\n\n@Suite(\"NostrTransport Tests\")\nstruct NostrTransportTests {\n    typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship\n\n    @Test(\"Warm cache marks full and short IDs reachable\")\n    @MainActor\n    func reachabilityCacheWarmsFromFavorites() async throws {\n        let keychain = MockKeychain()\n        let idBridge = NostrIdentityBridge(keychain: keychain)\n        let recipient = try NostrIdentity.generate()\n        let noiseKey = Data((0..<32).map(UInt8.init))\n        let fullPeerID = PeerID(hexData: noiseKey)\n        let shortPeerID = fullPeerID.toShort()\n        let relationship = makeRelationship(\n            peerNoisePublicKey: noiseKey,\n            peerNostrPublicKey: recipient.npub,\n            peerNickname: \"Alice\"\n        )\n        let favorites = [noiseKey: relationship]\n\n        let transport = NostrTransport(\n            keychain: keychain,\n            idBridge: idBridge,\n            dependencies: makeDependencies(\n                loadFavorites: { favorites },\n                favoriteStatusForNoiseKey: { favorites[$0] },\n                favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },\n                currentIdentity: { nil }\n            )\n        )\n\n        #expect(!transport.isPeerReachable(fullPeerID))\n        #expect(transport.isPeerReachable(shortPeerID))\n        #expect(!transport.isPeerReachable(PeerID(str: \"feedfeedfeedfeed\")))\n    }\n\n    @Test(\"Favorite status notification refreshes reachability cache\")\n    @MainActor\n    func favoriteStatusNotificationRefreshesReachability() async throws {\n        let keychain = MockKeychain()\n        let idBridge = NostrIdentityBridge(keychain: keychain)\n        let recipient = try NostrIdentity.generate()\n        let noiseKey = Data((32..<64).map(UInt8.init))\n        let peerID = PeerID(hexData: noiseKey).toShort()\n        let notificationCenter = NotificationCenter()\n        var favorites: [Data: FavoriteRelationship] = [:]\n\n        let transport = NostrTransport(\n            keychain: keychain,\n            idBridge: idBridge,\n            dependencies: makeDependencies(\n                notificationCenter: notificationCenter,\n                loadFavorites: { favorites },\n                favoriteStatusForNoiseKey: { favorites[$0] },\n                favoriteStatusForPeerID: { _ in favorites.values.first },\n                currentIdentity: { nil }\n            )\n        )\n\n        #expect(!transport.isPeerReachable(peerID))\n\n        favorites[noiseKey] = makeRelationship(\n            peerNoisePublicKey: noiseKey,\n            peerNostrPublicKey: recipient.npub,\n            peerNickname: \"Bob\"\n        )\n        notificationCenter.post(name: .favoriteStatusChanged, object: nil)\n\n        let didRefresh = await TestHelpers.waitUntil({ transport.isPeerReachable(peerID) }, timeout: 0.5)\n        #expect(didRefresh)\n    }\n\n    @Test(\"Private message resolves short peer ID and emits decryptable packet\")\n    @MainActor\n    func sendPrivateMessageResolvesShortPeerID() async throws {\n        let keychain = MockKeychain()\n        let idBridge = NostrIdentityBridge(keychain: keychain)\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        let noiseKey = Data((64..<96).map(UInt8.init))\n        let shortPeerID = PeerID(hexData: noiseKey).toShort()\n        let relationship = makeRelationship(\n            peerNoisePublicKey: noiseKey,\n            peerNostrPublicKey: recipient.npub,\n            peerNickname: \"Carol\"\n        )\n        let probe = NostrTransportProbe()\n        let transport = NostrTransport(\n            keychain: keychain,\n            idBridge: idBridge,\n            dependencies: makeDependencies(\n                favoriteStatusForNoiseKey: { _ in nil },\n                favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },\n                currentIdentity: { sender },\n                registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),\n                sendEvent: probe.record(event:),\n                scheduleAfter: { delay, action in\n                    probe.enqueueScheduledAction(delay: delay, action: action)\n                }\n            )\n        )\n        transport.senderPeerID = PeerID(str: \"0123456789abcdef\")\n\n        transport.sendPrivateMessage(\"hello over nostr\", to: shortPeerID, recipientNickname: \"Carol\", messageID: \"pm-1\")\n\n        let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5)\n        #expect(didSend)\n        let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)\n        let privateMessage = try decodePrivateMessage(from: result.payload)\n\n        #expect(result.senderPubkey == sender.publicKeyHex)\n        #expect(privateMessage.messageID == \"pm-1\")\n        #expect(privateMessage.content == \"hello over nostr\")\n        #expect(result.packet.recipientID == shortPeerID.routingData)\n        #expect(probe.pendingGiftWrapIDs.isEmpty)\n    }\n\n    @Test(\"Favorite notification embeds current npub\")\n    @MainActor\n    func sendFavoriteNotificationEmbedsCurrentIdentity() async throws {\n        let keychain = MockKeychain()\n        let idBridge = NostrIdentityBridge(keychain: keychain)\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        let noiseKey = Data((96..<128).map(UInt8.init))\n        let fullPeerID = PeerID(hexData: noiseKey)\n        let relationship = makeRelationship(\n            peerNoisePublicKey: noiseKey,\n            peerNostrPublicKey: recipient.npub,\n            peerNickname: \"Dan\"\n        )\n        let probe = NostrTransportProbe()\n        let transport = NostrTransport(\n            keychain: keychain,\n            idBridge: idBridge,\n            dependencies: makeDependencies(\n                favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },\n                favoriteStatusForPeerID: { _ in nil },\n                currentIdentity: { sender },\n                registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),\n                sendEvent: probe.record(event:),\n                scheduleAfter: { delay, action in\n                    probe.enqueueScheduledAction(delay: delay, action: action)\n                }\n            )\n        )\n        transport.senderPeerID = PeerID(str: \"0123456789abcdef\")\n\n        transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)\n\n        let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5)\n        #expect(didSend)\n        let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)\n        let privateMessage = try decodePrivateMessage(from: result.payload)\n\n        #expect(privateMessage.content == \"[FAVORITED]:\\(sender.npub)\")\n    }\n\n    @Test(\"Delivery ACK encodes delivered payload type\")\n    @MainActor\n    func sendDeliveryAckEmitsDeliveredAck() async throws {\n        let keychain = MockKeychain()\n        let idBridge = NostrIdentityBridge(keychain: keychain)\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        let noiseKey = Data((128..<160).map(UInt8.init))\n        let fullPeerID = PeerID(hexData: noiseKey)\n        let relationship = makeRelationship(\n            peerNoisePublicKey: noiseKey,\n            peerNostrPublicKey: recipient.npub,\n            peerNickname: \"Eve\"\n        )\n        let probe = NostrTransportProbe()\n        let transport = NostrTransport(\n            keychain: keychain,\n            idBridge: idBridge,\n            dependencies: makeDependencies(\n                favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },\n                favoriteStatusForPeerID: { _ in nil },\n                currentIdentity: { sender },\n                registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),\n                sendEvent: probe.record(event:),\n                scheduleAfter: { delay, action in\n                    probe.enqueueScheduledAction(delay: delay, action: action)\n                }\n            )\n        )\n        transport.senderPeerID = PeerID(str: \"0123456789abcdef\")\n\n        transport.sendDeliveryAck(for: \"ack-1\", to: fullPeerID)\n\n        let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5)\n        #expect(didSend)\n        let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)\n\n        #expect(result.payload.type == .delivered)\n        #expect(String(data: result.payload.data, encoding: .utf8) == \"ack-1\")\n        #expect(result.packet.recipientID == fullPeerID.toShort().routingData)\n    }\n\n    @Test(\"Geohash private message registers pending gift wrap\")\n    @MainActor\n    func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {\n        let keychain = MockKeychain()\n        let idBridge = NostrIdentityBridge(keychain: keychain)\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        let probe = NostrTransportProbe()\n        let transport = NostrTransport(\n            keychain: keychain,\n            idBridge: idBridge,\n            dependencies: makeDependencies(\n                currentIdentity: { sender },\n                registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),\n                sendEvent: probe.record(event:),\n                scheduleAfter: { delay, action in\n                    probe.enqueueScheduledAction(delay: delay, action: action)\n                }\n            )\n        )\n        transport.senderPeerID = PeerID(str: \"0123456789abcdef\")\n\n        transport.sendPrivateMessageGeohash(\n            content: \"geo hello\",\n            toRecipientHex: recipient.publicKeyHex,\n            from: sender,\n            messageID: \"geo-1\"\n        )\n\n        let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5)\n        #expect(didSend)\n        let event = probe.sentEvents[0]\n        let result = try decodeEmbeddedPayload(from: event, recipient: recipient)\n        let privateMessage = try decodePrivateMessage(from: result.payload)\n\n        #expect(privateMessage.messageID == \"geo-1\")\n        #expect(privateMessage.content == \"geo hello\")\n        #expect(result.packet.recipientID == nil)\n        #expect(probe.pendingGiftWrapIDs == [event.id])\n    }\n\n    @Test(\"Read receipt queue sends in order and waits for scheduler\")\n    @MainActor\n    func readReceiptQueueThrottlesSequentially() async throws {\n        let keychain = MockKeychain()\n        let idBridge = NostrIdentityBridge(keychain: keychain)\n        let sender = try NostrIdentity.generate()\n        let recipient = try NostrIdentity.generate()\n        let noiseKey = Data((160..<192).map(UInt8.init))\n        let fullPeerID = PeerID(hexData: noiseKey)\n        let relationship = makeRelationship(\n            peerNoisePublicKey: noiseKey,\n            peerNostrPublicKey: recipient.npub,\n            peerNickname: \"Frank\"\n        )\n        let probe = NostrTransportProbe()\n        let transport = NostrTransport(\n            keychain: keychain,\n            idBridge: idBridge,\n            dependencies: makeDependencies(\n                favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },\n                favoriteStatusForPeerID: { _ in nil },\n                currentIdentity: { sender },\n                registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),\n                sendEvent: probe.record(event:),\n                scheduleAfter: { delay, action in\n                    probe.enqueueScheduledAction(delay: delay, action: action)\n                }\n            )\n        )\n        transport.senderPeerID = PeerID(str: \"0123456789abcdef\")\n\n        let first = ReadReceipt(originalMessageID: \"read-1\", readerID: transport.myPeerID, readerNickname: \"Me\")\n        let second = ReadReceipt(originalMessageID: \"read-2\", readerID: transport.myPeerID, readerNickname: \"Me\")\n\n        transport.sendReadReceipt(first, to: fullPeerID)\n        transport.sendReadReceipt(second, to: fullPeerID)\n\n        let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 1.5)\n        try #require(sentFirst, \"Expected first queued read receipt event\")\n        let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: 1.5)\n        try #require(scheduledThrottle, \"Expected queued throttle action after first read receipt\")\n        let firstEvent = try #require(probe.sentEvents.first, \"Expected first queued read receipt event\")\n        let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload\n        #expect(firstPayload.type == .readReceipt)\n        #expect(String(data: firstPayload.data, encoding: .utf8) == \"read-1\")\n\n        try #require(probe.runNextScheduledAction(), \"Expected queued throttle action after first read receipt\")\n\n        let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 1.5)\n        try #require(sentSecond, \"Expected second read receipt after running throttle action\")\n        let secondEvent = try #require(probe.sentEvents.last, \"Expected second queued read receipt event\")\n        let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload\n        #expect(secondPayload.type == .readReceipt)\n        #expect(String(data: secondPayload.data, encoding: .utf8) == \"read-2\")\n    }\n\n    @Test(\"Concurrent read receipt enqueue does not crash\")\n    @MainActor\n    func concurrentReadReceiptEnqueue() async throws {\n        let keychain = MockKeychain()\n        let idBridge = NostrIdentityBridge(keychain: keychain)\n        let transport = NostrTransport(keychain: keychain, idBridge: idBridge)\n        let iterations = 100\n\n        await withTaskGroup(of: Void.self) { group in\n            for i in 0..<iterations {\n                group.addTask {\n                    let receipt = ReadReceipt(\n                        originalMessageID: UUID().uuidString,\n                        readerID: PeerID(str: String(format: \"%016x\", i)),\n                        readerNickname: \"Reader\\(i)\"\n                    )\n                    let peerID = PeerID(str: String(format: \"%016x\", i))\n                    transport.sendReadReceipt(receipt, to: peerID)\n                }\n            }\n        }\n    }\n\n    @Test(\"isPeerReachable is thread safe\")\n    @MainActor\n    func isPeerReachableThreadSafety() async throws {\n        let keychain = MockKeychain()\n        let idBridge = NostrIdentityBridge(keychain: keychain)\n        let transport = NostrTransport(keychain: keychain, idBridge: idBridge)\n        let iterations = 100\n\n        await withTaskGroup(of: Bool.self) { group in\n            for i in 0..<iterations {\n                group.addTask {\n                    let peerID = PeerID(str: String(format: \"%016x\", i))\n                    return transport.isPeerReachable(peerID)\n                }\n            }\n\n            for await result in group {\n                #expect(result == false)\n            }\n        }\n    }\n\n    @MainActor\n    private func makeDependencies(\n        notificationCenter: NotificationCenter = NotificationCenter(),\n        loadFavorites: @escaping @MainActor () -> [Data: FavoriteRelationship] = { [:] },\n        favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },\n        favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },\n        currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },\n        registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },\n        sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },\n        scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in }\n    ) -> NostrTransport.Dependencies {\n        NostrTransport.Dependencies(\n            notificationCenter: notificationCenter,\n            loadFavorites: loadFavorites,\n            favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,\n            favoriteStatusForPeerID: favoriteStatusForPeerID,\n            currentIdentity: currentIdentity,\n            registerPendingGiftWrap: registerPendingGiftWrap,\n            sendEvent: sendEvent,\n            scheduleAfter: scheduleAfter\n        )\n    }\n\n    private func makeRelationship(\n        peerNoisePublicKey: Data,\n        peerNostrPublicKey: String?,\n        peerNickname: String\n    ) -> FavoriteRelationship {\n        FavoriteRelationship(\n            peerNoisePublicKey: peerNoisePublicKey,\n            peerNostrPublicKey: peerNostrPublicKey,\n            peerNickname: peerNickname,\n            isFavorite: true,\n            theyFavoritedUs: true,\n            favoritedAt: Date(timeIntervalSince1970: 1),\n            lastUpdated: Date(timeIntervalSince1970: 2)\n        )\n    }\n\n    private func decodeEmbeddedPayload(\n        from event: NostrEvent,\n        recipient: NostrIdentity\n    ) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {\n        let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(\n            giftWrap: event,\n            recipientIdentity: recipient\n        )\n        guard content.hasPrefix(\"bitchat1:\") else {\n            throw NostrTransportTestError.invalidEmbeddedContent\n        }\n        let encoded = String(content.dropFirst(\"bitchat1:\".count))\n        guard let packetData = base64URLDecode(encoded),\n              let packet = BitchatPacket.from(packetData),\n              let payload = NoisePayload.decode(packet.payload) else {\n            throw NostrTransportTestError.invalidPacket\n        }\n        return (packet, payload, senderPubkey)\n    }\n\n    private func decodePrivateMessage(from payload: NoisePayload) throws -> PrivateMessagePacket {\n        guard payload.type == .privateMessage,\n              let message = PrivateMessagePacket.decode(from: payload.data) else {\n            throw NostrTransportTestError.invalidPrivateMessage\n        }\n        return message\n    }\n}\n\nprivate enum NostrTransportTestError: Error {\n    case invalidEmbeddedContent\n    case invalidPacket\n    case invalidPrivateMessage\n}\n\nprivate func base64URLDecode(_ string: String) -> Data? {\n    var candidate = string\n    let padding = (4 - (candidate.count % 4)) % 4\n    if padding > 0 {\n        candidate += String(repeating: \"=\", count: padding)\n    }\n    candidate = candidate\n        .replacingOccurrences(of: \"-\", with: \"+\")\n        .replacingOccurrences(of: \"_\", with: \"/\")\n    return Data(base64Encoded: candidate)\n}\n\nprivate final class NostrTransportProbe: @unchecked Sendable {\n    private let lock = NSLock()\n    private var sentEventsStorage: [NostrEvent] = []\n    private var pendingGiftWrapIDsStorage: [String] = []\n    private var scheduledActionsStorage: [(@Sendable () -> Void)] = []\n\n    var sentEvents: [NostrEvent] {\n        lock.lock()\n        defer { lock.unlock() }\n        return sentEventsStorage\n    }\n\n    var pendingGiftWrapIDs: [String] {\n        lock.lock()\n        defer { lock.unlock() }\n        return pendingGiftWrapIDsStorage\n    }\n\n    var scheduledActionCount: Int {\n        lock.lock()\n        defer { lock.unlock() }\n        return scheduledActionsStorage.count\n    }\n\n    func record(event: NostrEvent) {\n        lock.lock()\n        sentEventsStorage.append(event)\n        lock.unlock()\n    }\n\n    func recordPendingGiftWrap(id: String) {\n        lock.lock()\n        pendingGiftWrapIDsStorage.append(id)\n        lock.unlock()\n    }\n\n    func enqueueScheduledAction(delay: TimeInterval, action: @escaping @Sendable () -> Void) {\n        _ = delay\n        lock.lock()\n        scheduledActionsStorage.append(action)\n        lock.unlock()\n    }\n\n    @discardableResult\n    func runNextScheduledAction() -> Bool {\n        let action: (@Sendable () -> Void)?\n        lock.lock()\n        action = scheduledActionsStorage.isEmpty ? nil : scheduledActionsStorage.removeFirst()\n        lock.unlock()\n        guard let action else { return false }\n        action()\n        return true\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/NotificationServiceTests.swift",
    "content": "import XCTest\nimport UserNotifications\n@testable import bitchat\n\nfinal class NotificationServiceTests: XCTestCase {\n    func test_requestAuthorization_skipsWhenRunningTests() {\n        let authorizer = RecordingNotificationAuthorizer()\n        let service = NotificationService(\n            isRunningTestsProvider: { true },\n            authorizer: authorizer,\n            requestDeliverer: RecordingNotificationRequestDeliverer()\n        )\n\n        service.requestAuthorization()\n\n        XCTAssertEqual(authorizer.requestCallCount, 0)\n    }\n\n    func test_requestAuthorization_requestsAlertSoundAndBadgePermissions() {\n        let authorizer = RecordingNotificationAuthorizer()\n        let service = NotificationService(\n            isRunningTestsProvider: { false },\n            authorizer: authorizer,\n            requestDeliverer: RecordingNotificationRequestDeliverer()\n        )\n\n        service.requestAuthorization()\n\n        XCTAssertEqual(authorizer.requestCallCount, 1)\n        XCTAssertEqual(authorizer.lastOptions, [.alert, .sound, .badge])\n    }\n\n    func test_sendLocalNotification_buildsImmediateRequestWithUserInfo() {\n        let deliverer = RecordingNotificationRequestDeliverer()\n        let service = NotificationService(\n            isRunningTestsProvider: { false },\n            authorizer: RecordingNotificationAuthorizer(),\n            requestDeliverer: deliverer\n        )\n\n        service.sendLocalNotification(\n            title: \"Hello\",\n            body: \"World\",\n            identifier: \"custom-id\",\n            userInfo: [\"peerID\": \"abcd\"],\n            interruptionLevel: .timeSensitive\n        )\n\n        let request = deliverer.requests.singleValue\n        XCTAssertEqual(request?.identifier, \"custom-id\")\n        XCTAssertEqual(request?.content.title, \"Hello\")\n        XCTAssertEqual(request?.content.body, \"World\")\n        XCTAssertEqual(request?.content.userInfo[\"peerID\"] as? String, \"abcd\")\n        XCTAssertEqual(request?.content.interruptionLevel, .timeSensitive)\n        XCTAssertNil(request?.trigger)\n    }\n\n    func test_sendPrivateMessageNotification_populatesPeerMetadata() {\n        let deliverer = RecordingNotificationRequestDeliverer()\n        let service = NotificationService(\n            isRunningTestsProvider: { false },\n            authorizer: RecordingNotificationAuthorizer(),\n            requestDeliverer: deliverer\n        )\n        let peerID = PeerID(str: \"deadbeefdeadbeef\")\n\n        service.sendPrivateMessageNotification(from: \"Alice\", message: \"hi\", peerID: peerID)\n\n        let request = deliverer.requests.singleValue\n        XCTAssertEqual(request?.content.title, \"🔒 DM from Alice\")\n        XCTAssertEqual(request?.content.body, \"hi\")\n        XCTAssertEqual(request?.content.userInfo[\"peerID\"] as? String, peerID.id)\n        XCTAssertEqual(request?.content.userInfo[\"senderName\"] as? String, \"Alice\")\n    }\n\n    func test_wrapperNotifications_setExpectedIdentifiersAndDeepLinks() {\n        let deliverer = RecordingNotificationRequestDeliverer()\n        let service = NotificationService(\n            isRunningTestsProvider: { false },\n            authorizer: RecordingNotificationAuthorizer(),\n            requestDeliverer: deliverer\n        )\n\n        service.sendGeohashActivityNotification(geohash: \"87yv\", bodyPreview: \"Someone is here\")\n        service.sendNetworkAvailableNotification(peerCount: 2)\n\n        XCTAssertEqual(deliverer.requests.count, 2)\n        XCTAssertEqual(deliverer.requests[0].content.userInfo[\"deeplink\"] as? String, \"bitchat://geohash/87yv\")\n        XCTAssertTrue(deliverer.requests[0].identifier.hasPrefix(\"geo-activity-87yv-\"))\n        XCTAssertEqual(deliverer.requests[1].identifier, \"network-available\")\n        XCTAssertEqual(deliverer.requests[1].content.interruptionLevel, .timeSensitive)\n        XCTAssertEqual(deliverer.requests[1].content.body, \"2 people around\")\n    }\n}\n\nprivate final class RecordingNotificationAuthorizer: NotificationAuthorizing {\n    private(set) var requestCallCount = 0\n    private(set) var lastOptions: UNAuthorizationOptions?\n\n    func requestAuthorization(\n        options: UNAuthorizationOptions,\n        completionHandler: @escaping (Bool, Error?) -> Void\n    ) {\n        requestCallCount += 1\n        lastOptions = options\n        completionHandler(true, nil)\n    }\n}\n\nprivate final class RecordingNotificationRequestDeliverer: NotificationRequestDelivering {\n    private(set) var requests: [UNNotificationRequest] = []\n\n    func add(_ request: UNNotificationRequest) {\n        requests.append(request)\n    }\n}\n\nprivate extension Array {\n    var singleValue: Element? {\n        count == 1 ? self[0] : nil\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/PrivateChatManagerTests.swift",
    "content": "//\n// PrivateChatManagerTests.swift\n// bitchatTests\n//\n// Tests for PrivateChatManager read receipt and selection behavior.\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\nstruct PrivateChatManagerTests {\n\n    @Test @MainActor\n    func startChat_setsSelectedAndClearsUnread() async {\n        let transport = MockTransport()\n        let manager = PrivateChatManager(meshService: transport)\n        let peerID = PeerID(str: \"00000000000000AA\")\n\n        manager.privateChats[peerID] = [\n            BitchatMessage(\n                id: \"pm-1\",\n                sender: \"Peer\",\n                content: \"Hi\",\n                timestamp: Date(),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Me\",\n                senderPeerID: peerID\n            )\n        ]\n        manager.unreadMessages.insert(peerID)\n\n        manager.startChat(with: peerID)\n\n        #expect(manager.selectedPeer == peerID)\n        #expect(!manager.unreadMessages.contains(peerID))\n        #expect(manager.privateChats[peerID] != nil)\n    }\n\n    @Test @MainActor\n    func markAsRead_sendsReadReceiptViaRouter() async {\n        let transport = MockTransport()\n        let router = MessageRouter(transports: [transport])\n        let manager = PrivateChatManager(meshService: transport)\n        manager.messageRouter = router\n\n        let peerID = PeerID(str: \"00000000000000BB\")\n        transport.reachablePeers.insert(peerID)\n\n        manager.privateChats[peerID] = [\n            BitchatMessage(\n                id: \"pm-2\",\n                sender: \"Peer\",\n                content: \"Hi\",\n                timestamp: Date(),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Me\",\n                senderPeerID: peerID\n            )\n        ]\n        manager.unreadMessages.insert(peerID)\n\n        manager.markAsRead(from: peerID)\n        try? await Task.sleep(nanoseconds: 100_000_000)\n\n        #expect(transport.sentReadReceipts.count == 1)\n        #expect(manager.sentReadReceipts.contains(\"pm-2\"))\n        #expect(!manager.unreadMessages.contains(peerID))\n    }\n\n    @Test @MainActor\n    func markAsRead_withoutRouterFallsBackToTransport() async {\n        let transport = MockTransport()\n        let manager = PrivateChatManager(meshService: transport)\n        let peerID = PeerID(str: \"00000000000000CC\")\n\n        manager.privateChats[peerID] = [\n            BitchatMessage(\n                id: \"pm-fallback\",\n                sender: \"Peer\",\n                content: \"Hi\",\n                timestamp: Date(),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Me\",\n                senderPeerID: peerID\n            )\n        ]\n\n        manager.markAsRead(from: peerID)\n\n        #expect(transport.sentReadReceipts.count == 1)\n        #expect(transport.sentReadReceipts.first?.receipt.originalMessageID == \"pm-fallback\")\n    }\n\n    @Test @MainActor\n    func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async {\n        let transport = MockTransport()\n        let manager = PrivateChatManager(meshService: transport)\n        let identityManager = MockIdentityManager(MockKeychain())\n        let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())\n        let unifiedPeerService = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identityManager)\n        manager.unifiedPeerService = unifiedPeerService\n\n        let peerID = PeerID(str: \"0123456789abcdef\")\n        let noiseKey = Data((0..<32).map(UInt8.init))\n        let stablePeerID = PeerID(hexData: noiseKey)\n\n        transport.updatePeerSnapshots([\n            TransportPeerSnapshot(\n                peerID: peerID,\n                nickname: \"Alice\",\n                isConnected: true,\n                noisePublicKey: noiseKey,\n                lastSeen: Date()\n            )\n        ])\n        try? await Task.sleep(nanoseconds: 50_000_000)\n\n        manager.privateChats[stablePeerID] = [\n            BitchatMessage(\n                id: \"stable-msg\",\n                sender: \"Alice\",\n                content: \"Hello from stable\",\n                timestamp: Date(),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Me\",\n                senderPeerID: stablePeerID\n            )\n        ]\n        manager.unreadMessages.insert(stablePeerID)\n\n        let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: \"Alice\", persistedReadReceipts: [])\n\n        #expect(hadUnread)\n        #expect(manager.privateChats[stablePeerID] == nil)\n        #expect(manager.privateChats[peerID]?.count == 1)\n        #expect(manager.privateChats[peerID]?.first?.senderPeerID == peerID)\n        #expect(manager.unreadMessages.contains(peerID))\n    }\n\n    @Test @MainActor\n    func consolidateMessages_movesTemporaryGeoDMHistoryByNickname() async {\n        let transport = MockTransport()\n        let manager = PrivateChatManager(meshService: transport)\n        let peerID = PeerID(str: \"0011223344556677\")\n        let tempPeerID = PeerID(nostr_: \"0000000000000000000000000000000000000000000000000000000000000042\")\n\n        manager.privateChats[tempPeerID] = [\n            BitchatMessage(\n                id: \"geo-msg\",\n                sender: \"Alice\",\n                content: \"Geo hello\",\n                timestamp: Date(),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Me\",\n                senderPeerID: tempPeerID\n            )\n        ]\n        manager.unreadMessages.insert(tempPeerID)\n\n        let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: \"alice\", persistedReadReceipts: [])\n\n        #expect(hadUnread)\n        #expect(manager.privateChats[tempPeerID] == nil)\n        #expect(manager.privateChats[peerID]?.count == 1)\n        #expect(manager.privateChats[peerID]?.first?.senderPeerID == peerID)\n        #expect(manager.unreadMessages.contains(peerID))\n        #expect(!manager.unreadMessages.contains(tempPeerID))\n    }\n\n    @Test @MainActor\n    func syncReadReceiptsForSentMessages_onlyCopiesDeliveredAndRead() async {\n        let transport = MockTransport()\n        let manager = PrivateChatManager(meshService: transport)\n        let peerID = PeerID(str: \"00000000000000DD\")\n\n        manager.privateChats[peerID] = [\n            BitchatMessage(\n                id: \"sent-read\",\n                sender: \"Me\",\n                content: \"One\",\n                timestamp: Date(),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Peer\",\n                senderPeerID: transport.myPeerID,\n                deliveryStatus: .read(by: \"Peer\", at: Date())\n            ),\n            BitchatMessage(\n                id: \"sent-delivered\",\n                sender: \"Me\",\n                content: \"Two\",\n                timestamp: Date(),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Peer\",\n                senderPeerID: transport.myPeerID,\n                deliveryStatus: .delivered(to: \"Peer\", at: Date())\n            ),\n            BitchatMessage(\n                id: \"sent-failed\",\n                sender: \"Me\",\n                content: \"Three\",\n                timestamp: Date(),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Peer\",\n                senderPeerID: transport.myPeerID,\n                deliveryStatus: .failed(reason: \"nope\")\n            )\n        ]\n\n        var externalReceipts = Set<String>()\n        manager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: \"Me\", externalReceipts: &externalReceipts)\n\n        #expect(externalReceipts == Set([\"sent-read\", \"sent-delivered\"]))\n        #expect(manager.sentReadReceipts == Set([\"sent-read\", \"sent-delivered\"]))\n    }\n\n    @Test @MainActor\n    func sanitizeChat_sortsChronologicallyAndKeepsLatestDuplicate() async {\n        let transport = MockTransport()\n        let manager = PrivateChatManager(meshService: transport)\n        let peerID = PeerID(str: \"00000000000000EE\")\n        let base = Date(timeIntervalSince1970: 10)\n\n        manager.privateChats[peerID] = [\n            BitchatMessage(\n                id: \"same\",\n                sender: \"Peer\",\n                content: \"Older\",\n                timestamp: base.addingTimeInterval(10),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Me\",\n                senderPeerID: peerID\n            ),\n            BitchatMessage(\n                id: \"first\",\n                sender: \"Peer\",\n                content: \"First\",\n                timestamp: base,\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Me\",\n                senderPeerID: peerID\n            ),\n            BitchatMessage(\n                id: \"same\",\n                sender: \"Peer\",\n                content: \"Newest\",\n                timestamp: base.addingTimeInterval(20),\n                isRelay: false,\n                isPrivate: true,\n                recipientNickname: \"Me\",\n                senderPeerID: peerID\n            )\n        ]\n\n        manager.sanitizeChat(for: peerID)\n\n        #expect(manager.privateChats[peerID]?.map(\\.id) == [\"first\", \"same\"])\n        #expect(manager.privateChats[peerID]?.last?.content == \"Newest\")\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/RelayControllerTests.swift",
    "content": "//\n// RelayControllerTests.swift\n// bitchatTests\n//\n// Tests for relay decision logic.\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\nstruct RelayControllerTests {\n\n    @Test\n    func ttlOne_doesNotRelay() async {\n        let decision = RelayController.decide(\n            ttl: 1,\n            senderIsSelf: false,\n            isEncrypted: false,\n            isDirectedEncrypted: false,\n            isFragment: false,\n            isDirectedFragment: false,\n            isHandshake: false,\n            isAnnounce: false,\n            degree: 0,\n            highDegreeThreshold: TransportConfig.bleHighDegreeThreshold\n        )\n\n        #expect(!decision.shouldRelay)\n        #expect(decision.newTTL == 1)\n    }\n\n    @Test\n    func handshake_alwaysRelaysWithTTLDecrement() async {\n        let decision = RelayController.decide(\n            ttl: 3,\n            senderIsSelf: false,\n            isEncrypted: false,\n            isDirectedEncrypted: false,\n            isFragment: false,\n            isDirectedFragment: false,\n            isHandshake: true,\n            isAnnounce: false,\n            degree: 3,\n            highDegreeThreshold: TransportConfig.bleHighDegreeThreshold\n        )\n\n        #expect(decision.shouldRelay)\n        #expect(decision.newTTL == 2)\n        #expect(decision.delayMs >= 10 && decision.delayMs <= 35)\n    }\n\n    @Test\n    func fragment_relaysWithFragmentCap() async {\n        let decision = RelayController.decide(\n            ttl: 10,\n            senderIsSelf: false,\n            isEncrypted: false,\n            isDirectedEncrypted: false,\n            isFragment: true,\n            isDirectedFragment: false,\n            isHandshake: false,\n            isAnnounce: false,\n            degree: 3,\n            highDegreeThreshold: TransportConfig.bleHighDegreeThreshold\n        )\n\n        let ttlCap = min(UInt8(10), TransportConfig.bleFragmentRelayTtlCap)\n        let expected = ttlCap &- 1\n\n        #expect(decision.shouldRelay)\n        #expect(decision.newTTL == expected)\n        #expect(decision.delayMs >= TransportConfig.bleFragmentRelayMinDelayMs)\n        #expect(decision.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs)\n    }\n\n    @Test\n    func denseGraph_capsTTL() async {\n        let decision = RelayController.decide(\n            ttl: 10,\n            senderIsSelf: false,\n            isEncrypted: false,\n            isDirectedEncrypted: false,\n            isFragment: false,\n            isDirectedFragment: false,\n            isHandshake: false,\n            isAnnounce: false,\n            degree: TransportConfig.bleHighDegreeThreshold,\n            highDegreeThreshold: TransportConfig.bleHighDegreeThreshold\n        )\n\n        #expect(decision.shouldRelay)\n        #expect(decision.newTTL == 4)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/SecureIdentityStateManagerTests.swift",
    "content": "import Foundation\nimport XCTest\n@testable import bitchat\n\nfinal class SecureIdentityStateManagerTests: XCTestCase {\n    func test_upsertCryptographicIdentity_withoutClaimedNicknameDoesNotCreateSocialIdentity() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let fingerprint = String(repeating: \"aa\", count: 32)\n        let peerID = PeerID(str: String(fingerprint.prefix(16)))\n\n        manager.upsertCryptographicIdentity(\n            fingerprint: fingerprint,\n            noisePublicKey: Data(repeating: 0x11, count: 32),\n            signingPublicKey: Data(repeating: 0x22, count: 32),\n            claimedNickname: nil\n        )\n\n        let inserted = await waitUntil {\n            manager.getCryptoIdentitiesByPeerIDPrefix(peerID).count == 1\n        }\n        XCTAssertTrue(inserted)\n        XCTAssertNil(manager.getSocialIdentity(for: fingerprint))\n    }\n\n    func test_upsertCryptographicIdentity_updatesExistingKeyAndPreservesSigningKey() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let fingerprint = String(repeating: \"ab\", count: 32)\n        let peerID = PeerID(str: String(fingerprint.prefix(16)))\n        let originalNoiseKey = Data(repeating: 0x11, count: 32)\n        let updatedNoiseKey = Data(repeating: 0x33, count: 32)\n        let signingKey = Data(repeating: 0x22, count: 32)\n\n        manager.upsertCryptographicIdentity(\n            fingerprint: fingerprint,\n            noisePublicKey: originalNoiseKey,\n            signingPublicKey: signingKey,\n            claimedNickname: nil\n        )\n        _ = await waitUntil {\n            manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.publicKey == originalNoiseKey\n        }\n\n        manager.upsertCryptographicIdentity(\n            fingerprint: fingerprint,\n            noisePublicKey: updatedNoiseKey,\n            signingPublicKey: nil,\n            claimedNickname: nil\n        )\n\n        let updated = await waitUntil {\n            guard let identity = manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first else { return false }\n            return identity.publicKey == updatedNoiseKey && identity.signingPublicKey == signingKey\n        }\n        XCTAssertTrue(updated)\n    }\n\n    func test_upsertCryptographicIdentity_tracksByPeerIDPrefixAndClaimedNickname() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let noisePublicKey = Data(repeating: 0x11, count: 32)\n        let signingPublicKey = Data(repeating: 0x22, count: 32)\n        let fingerprint = noisePublicKey.sha256Fingerprint()\n\n        manager.upsertCryptographicIdentity(\n            fingerprint: fingerprint,\n            noisePublicKey: noisePublicKey,\n            signingPublicKey: signingPublicKey,\n            claimedNickname: \"Alice\"\n        )\n\n        let socialIdentityLoaded = await waitUntil {\n            manager.getSocialIdentity(for: fingerprint)?.claimedNickname == \"Alice\"\n        }\n        XCTAssertTrue(socialIdentityLoaded)\n        let matches = manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(publicKey: noisePublicKey))\n        XCTAssertEqual(matches.count, 1)\n        XCTAssertEqual(matches.first?.fingerprint, fingerprint)\n        XCTAssertEqual(matches.first?.publicKey, noisePublicKey)\n        XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)\n    }\n\n    func test_setBlocked_clearsFavoriteState() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let fingerprint = String(repeating: \"ab\", count: 32)\n\n        manager.setFavorite(fingerprint, isFavorite: true)\n        let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }\n        XCTAssertTrue(favoriteSet)\n\n        manager.setBlocked(fingerprint, isBlocked: true)\n        let blockedSet = await waitUntil { manager.isBlocked(fingerprint: fingerprint) }\n        XCTAssertTrue(blockedSet)\n\n        XCTAssertFalse(manager.isFavorite(fingerprint: fingerprint))\n        XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, \"Unknown\")\n    }\n\n    func test_isBlocked_unknownFingerprintReturnsFalse() {\n        let manager = SecureIdentityStateManager(MockKeychain())\n\n        XCTAssertFalse(manager.isBlocked(fingerprint: String(repeating: \"ff\", count: 32)))\n    }\n\n    func test_setVerified_updatesTrustLevelAndVerifiedSet() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let fingerprint = String(repeating: \"cd\", count: 32)\n\n        manager.setFavorite(fingerprint, isFavorite: false)\n        _ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }\n        manager.setVerified(fingerprint: fingerprint, verified: true)\n\n        let verifiedSet = await waitUntil { manager.isVerified(fingerprint: fingerprint) }\n        XCTAssertTrue(verifiedSet)\n        XCTAssertTrue(manager.getVerifiedFingerprints().contains(fingerprint))\n        XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.trustLevel, .verified)\n    }\n\n    func test_forceSave_persistsFavoriteStateAcrossReinit() async {\n        let keychain = MockKeychain()\n        let manager = SecureIdentityStateManager(keychain)\n        let fingerprint = String(repeating: \"ef\", count: 32)\n\n        manager.setFavorite(fingerprint, isFavorite: true)\n        let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }\n        XCTAssertTrue(favoriteSet)\n        manager.forceSave()\n\n        let reloaded = SecureIdentityStateManager(keychain)\n        XCTAssertTrue(reloaded.isFavorite(fingerprint: fingerprint))\n    }\n\n    func test_updateSocialIdentity_reindexesClaimedNickname() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let fingerprint = String(repeating: \"34\", count: 32)\n\n        manager.updateSocialIdentity(\n            SocialIdentity(\n                fingerprint: fingerprint,\n                localPetname: nil,\n                claimedNickname: \"Alice\",\n                trustLevel: .unknown,\n                isFavorite: false,\n                isBlocked: false,\n                notes: nil\n            )\n        )\n        let initialIndexed = await waitUntil {\n            manager.debugNicknameIndex[\"Alice\"]?.contains(fingerprint) == true\n        }\n        XCTAssertTrue(initialIndexed)\n\n        manager.updateSocialIdentity(\n            SocialIdentity(\n                fingerprint: fingerprint,\n                localPetname: \"Friend\",\n                claimedNickname: \"Bob\",\n                trustLevel: .trusted,\n                isFavorite: true,\n                isBlocked: false,\n                notes: \"updated\"\n            )\n        )\n\n        let reindexed = await waitUntil {\n            manager.debugNicknameIndex[\"Alice\"]?.contains(fingerprint) != true &&\n            manager.debugNicknameIndex[\"Bob\"]?.contains(fingerprint) == true\n        }\n        XCTAssertTrue(reindexed)\n        XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, \"Bob\")\n    }\n\n    func test_upsertCryptographicIdentity_sameClaimedNicknamePreservesExistingSocialIdentity() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let fingerprint = String(repeating: \"35\", count: 32)\n\n        manager.updateSocialIdentity(\n            SocialIdentity(\n                fingerprint: fingerprint,\n                localPetname: \"Pal\",\n                claimedNickname: \"Alice\",\n                trustLevel: .trusted,\n                isFavorite: true,\n                isBlocked: false,\n                notes: \"keep me\"\n            )\n        )\n        _ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }\n\n        manager.upsertCryptographicIdentity(\n            fingerprint: fingerprint,\n            noisePublicKey: Data(repeating: 0x11, count: 32),\n            signingPublicKey: Data(repeating: 0x22, count: 32),\n            claimedNickname: \"Alice\"\n        )\n\n        let inserted = await waitUntil {\n            manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: String(fingerprint.prefix(16)))).count == 1\n        }\n        XCTAssertTrue(inserted)\n        XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.localPetname, \"Pal\")\n        XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.notes, \"keep me\")\n        XCTAssertTrue(manager.getSocialIdentity(for: fingerprint)?.isFavorite == true)\n    }\n\n    func test_getFavorites_returnsOnlyFavoritedFingerprints() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let favoriteOne = String(repeating: \"45\", count: 32)\n        let favoriteTwo = String(repeating: \"56\", count: 32)\n        let other = String(repeating: \"67\", count: 32)\n\n        manager.setFavorite(favoriteOne, isFavorite: true)\n        manager.setFavorite(favoriteTwo, isFavorite: true)\n        manager.setFavorite(other, isFavorite: false)\n\n        let favoritesLoaded = await waitUntil {\n            manager.getFavorites() == Set([favoriteOne, favoriteTwo])\n        }\n        XCTAssertTrue(favoritesLoaded)\n    }\n\n    func test_setFavorite_existingIdentityCanBeClearedWithoutChangingNickname() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let fingerprint = String(repeating: \"68\", count: 32)\n\n        manager.updateSocialIdentity(\n            SocialIdentity(\n                fingerprint: fingerprint,\n                localPetname: nil,\n                claimedNickname: \"Alice\",\n                trustLevel: .trusted,\n                isFavorite: false,\n                isBlocked: false,\n                notes: nil\n            )\n        )\n        _ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }\n\n        manager.setFavorite(fingerprint, isFavorite: true)\n        _ = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }\n\n        manager.setFavorite(fingerprint, isFavorite: false)\n        let cleared = await waitUntil {\n            !manager.isFavorite(fingerprint: fingerprint) &&\n            manager.getSocialIdentity(for: fingerprint)?.claimedNickname == \"Alice\" &&\n            manager.getSocialIdentity(for: fingerprint)?.trustLevel == .trusted\n        }\n        XCTAssertTrue(cleared)\n    }\n\n    func test_setBlocked_createsIdentityAndCanLaterUnblock() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let fingerprint = String(repeating: \"78\", count: 32)\n\n        manager.setBlocked(fingerprint, isBlocked: true)\n        let blocked = await waitUntil {\n            manager.isBlocked(fingerprint: fingerprint)\n        }\n        XCTAssertTrue(blocked)\n        XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, \"Unknown\")\n\n        manager.setBlocked(fingerprint, isBlocked: false)\n        let unblocked = await waitUntil {\n            !manager.isBlocked(fingerprint: fingerprint)\n        }\n        XCTAssertTrue(unblocked)\n    }\n\n    func test_setVerified_false_downgradesTrustLevelToCasual() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let fingerprint = String(repeating: \"89\", count: 32)\n\n        manager.updateSocialIdentity(\n            SocialIdentity(\n                fingerprint: fingerprint,\n                localPetname: nil,\n                claimedNickname: \"Verifier\",\n                trustLevel: .trusted,\n                isFavorite: false,\n                isBlocked: false,\n                notes: nil\n            )\n        )\n        _ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }\n\n        manager.setVerified(fingerprint: fingerprint, verified: true)\n        _ = await waitUntil { manager.isVerified(fingerprint: fingerprint) }\n\n        manager.setVerified(fingerprint: fingerprint, verified: false)\n        let downgraded = await waitUntil {\n            !manager.isVerified(fingerprint: fingerprint) &&\n            manager.getSocialIdentity(for: fingerprint)?.trustLevel == .casual\n        }\n        XCTAssertTrue(downgraded)\n    }\n\n    func test_ephemeralSessionLifecycle_tracksHandshakeProgressAndLastInteraction() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let peerID = PeerID(str: \"1234567890abcdef\")\n        let fingerprint = String(repeating: \"90\", count: 32)\n\n        manager.registerEphemeralSession(peerID: peerID, handshakeState: .initiated)\n        let registered = await waitUntil {\n            if case .initiated? = manager.debugEphemeralSession(for: peerID)?.handshakeState {\n                return true\n            }\n            return false\n        }\n        XCTAssertTrue(registered)\n\n        manager.updateHandshakeState(peerID: peerID, state: .inProgress)\n        let progressed = await waitUntil {\n            if case .inProgress? = manager.debugEphemeralSession(for: peerID)?.handshakeState {\n                return true\n            }\n            return false\n        }\n        XCTAssertTrue(progressed)\n\n        manager.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprint))\n        let completed = await waitUntil {\n            if case .completed(let completedFingerprint)? = manager.debugEphemeralSession(for: peerID)?.handshakeState {\n                return completedFingerprint == fingerprint && manager.debugLastInteraction(for: fingerprint) != nil\n            }\n            return false\n        }\n        XCTAssertTrue(completed)\n\n        manager.removeEphemeralSession(peerID: peerID)\n        let removed = await waitUntil {\n            manager.debugEphemeralSession(for: peerID) == nil\n        }\n        XCTAssertTrue(removed)\n    }\n\n    func test_setNostrBlocked_normalizesToLowercaseAndPersists() async {\n        let keychain = MockKeychain()\n        let manager = SecureIdentityStateManager(keychain)\n        let pubkey = \"ABCDEF1234\"\n\n        manager.setNostrBlocked(pubkey, isBlocked: true)\n        let nostrBlocked = await waitUntil {\n            manager.isNostrBlocked(pubkeyHexLowercased: pubkey.lowercased())\n        }\n        XCTAssertTrue(nostrBlocked)\n        manager.forceSave()\n\n        let reloaded = SecureIdentityStateManager(keychain)\n        XCTAssertEqual(reloaded.getBlockedNostrPubkeys(), Set([pubkey.lowercased()]))\n        XCTAssertTrue(reloaded.isNostrBlocked(pubkeyHexLowercased: pubkey))\n    }\n\n    func test_setNostrBlocked_falseRemovesExistingKey() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let pubkey = \"ABCDEF1234\"\n\n        manager.setNostrBlocked(pubkey, isBlocked: true)\n        _ = await waitUntil { manager.isNostrBlocked(pubkeyHexLowercased: pubkey) }\n\n        manager.setNostrBlocked(pubkey, isBlocked: false)\n        let cleared = await waitUntil {\n            !manager.isNostrBlocked(pubkeyHexLowercased: pubkey) &&\n            manager.getBlockedNostrPubkeys().isEmpty\n        }\n        XCTAssertTrue(cleared)\n    }\n\n    func test_corruptPersistedCache_fallsBackToEmptyState() {\n        let keychain = MockKeychain()\n        _ = keychain.saveIdentityKey(Data(repeating: 0x01, count: 32), forKey: \"identityCacheEncryptionKey\")\n        _ = keychain.saveIdentityKey(Data([0xFF, 0x00, 0xAA]), forKey: \"bitchat.identityCache.v2\")\n\n        let manager = SecureIdentityStateManager(keychain)\n\n        XCTAssertTrue(manager.getFavorites().isEmpty)\n        XCTAssertTrue(manager.getVerifiedFingerprints().isEmpty)\n        XCTAssertTrue(manager.getBlockedNostrPubkeys().isEmpty)\n    }\n\n    func test_clearAllIdentityData_removesCachedState() async {\n        let manager = SecureIdentityStateManager(MockKeychain())\n        let fingerprint = String(repeating: \"12\", count: 32)\n\n        manager.setFavorite(fingerprint, isFavorite: true)\n        manager.setVerified(fingerprint: fingerprint, verified: true)\n        manager.setNostrBlocked(\"ABCD\", isBlocked: true)\n        let primed = await waitUntil {\n            manager.isFavorite(fingerprint: fingerprint) &&\n            manager.isVerified(fingerprint: fingerprint)\n        }\n        XCTAssertTrue(primed)\n\n        manager.clearAllIdentityData()\n        let cleared = await waitUntil {\n            !manager.isFavorite(fingerprint: fingerprint) &&\n            !manager.isVerified(fingerprint: fingerprint) &&\n            manager.getBlockedNostrPubkeys().isEmpty\n        }\n        XCTAssertTrue(cleared)\n    }\n\n    func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {\n        let keychain = FailingCacheSaveKeychain()\n        let manager = SecureIdentityStateManager(keychain)\n        let fingerprint = String(repeating: \"de\", count: 32)\n\n        manager.setFavorite(fingerprint, isFavorite: true)\n        let primed = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }\n        XCTAssertTrue(primed)\n\n        manager.forceSave()\n\n        let reloaded = SecureIdentityStateManager(keychain)\n        XCTAssertFalse(reloaded.isFavorite(fingerprint: fingerprint))\n    }\n\n    private func waitUntil(\n        timeout: TimeInterval = 1.0,\n        condition: @escaping () -> Bool\n    ) async -> Bool {\n        let deadline = Date().addingTimeInterval(timeout)\n        while Date() < deadline {\n            if condition() {\n                return true\n            }\n            try? await Task.sleep(nanoseconds: 10_000_000)\n        }\n        return condition()\n    }\n}\n\nprivate final class FailingCacheSaveKeychain: KeychainManagerProtocol {\n    private var storage: [String: Data] = [:]\n    private var serviceStorage: [String: [String: Data]] = [:]\n\n    func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {\n        if key == \"bitchat.identityCache.v2\" {\n            return false\n        }\n        storage[key] = keyData\n        return true\n    }\n\n    func getIdentityKey(forKey key: String) -> Data? {\n        storage[key]\n    }\n\n    func deleteIdentityKey(forKey key: String) -> Bool {\n        storage.removeValue(forKey: key)\n        return true\n    }\n\n    func deleteAllKeychainData() -> Bool {\n        storage.removeAll()\n        serviceStorage.removeAll()\n        return true\n    }\n\n    func secureClear(_ data: inout Data) {\n        data = Data()\n    }\n\n    func secureClear(_ string: inout String) {\n        string = \"\"\n    }\n\n    func verifyIdentityKeyExists() -> Bool {\n        storage[\"identity_noiseStaticKey\"] != nil\n    }\n\n    func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {\n        if let data = storage[key] {\n            return .success(data)\n        }\n        return .itemNotFound\n    }\n\n    func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {\n        if saveIdentityKey(keyData, forKey: key) {\n            return .success\n        }\n        return .otherError(OSStatus(-1))\n    }\n\n    func save(key: String, data: Data, service: String, accessible: CFString?) {\n        if serviceStorage[service] == nil {\n            serviceStorage[service] = [:]\n        }\n        serviceStorage[service]?[key] = data\n    }\n\n    func load(key: String, service: String) -> Data? {\n        serviceStorage[service]?[key]\n    }\n\n    func delete(key: String, service: String) {\n        serviceStorage[service]?.removeValue(forKey: key)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/TransferProgressManagerTests.swift",
    "content": "import Foundation\nimport Combine\nimport Testing\n@testable import bitchat\n\n@Suite(\"TransferProgressManager Tests\")\nstruct TransferProgressManagerTests {\n\n    @Test(\"Start publishes started event and stores snapshot\")\n    @MainActor\n    func startPublishesAndStoresSnapshot() async throws {\n        let manager = TransferProgressManager()\n        let transferID = \"transfer-start\"\n        var cancellable: AnyCancellable?\n        let recorder = EventRecorder()\n\n        cancellable = manager.publisher.sink { event in\n            if case .started(let id, let total) = event {\n                recorder.append(\"started:\\(id):\\(total)\")\n            }\n        }\n\n        manager.start(id: transferID, totalFragments: 3)\n\n        let didReceive = await TestHelpers.waitUntil({\n            recorder.values == [\"started:\\(transferID):3\"]\n        }, timeout: 0.5)\n        #expect(didReceive)\n\n        #expect(recorder.values == [\"started:\\(transferID):3\"])\n        #expect(manager.snapshot(id: transferID)?.sent == 0)\n        #expect(manager.snapshot(id: transferID)?.total == 3)\n        _ = cancellable\n    }\n\n    @Test(\"Sending final fragment publishes update and completion then clears snapshot\")\n    @MainActor\n    func recordFragmentSentPublishesProgressAndCompletion() async throws {\n        let manager = TransferProgressManager()\n        let transferID = \"transfer-complete\"\n        var cancellable: AnyCancellable?\n        let recorder = EventRecorder()\n\n        cancellable = manager.publisher.sink { event in\n            switch event {\n            case .started(let id, let total):\n                recorder.append(\"started:\\(id):\\(total)\")\n            case .updated(let id, let sent, let total):\n                recorder.append(\"updated:\\(id):\\(sent):\\(total)\")\n            case .completed(let id, let total):\n                recorder.append(\"completed:\\(id):\\(total)\")\n            case .cancelled:\n                break\n            }\n        }\n\n        manager.start(id: transferID, totalFragments: 1)\n        manager.recordFragmentSent(id: transferID)\n\n        let didReceive = await TestHelpers.waitUntil({\n            recorder.values.count == 3\n        }, timeout: 0.5)\n        #expect(didReceive)\n\n        #expect(recorder.values == [\n            \"started:\\(transferID):1\",\n            \"updated:\\(transferID):1:1\",\n            \"completed:\\(transferID):1\"\n        ])\n        #expect(manager.snapshot(id: transferID) == nil)\n        _ = cancellable\n    }\n\n    @Test(\"Cancel publishes cancelled event and clears state\")\n    @MainActor\n    func cancelPublishesAndClearsState() async throws {\n        let manager = TransferProgressManager()\n        let transferID = \"transfer-cancel\"\n        var cancellable: AnyCancellable?\n        let recorder = EventRecorder()\n\n        cancellable = manager.publisher.sink { event in\n            switch event {\n            case .started(let id, let total):\n                recorder.append(\"started:\\(id):\\(total)\")\n            case .cancelled(let id, let sent, let total):\n                recorder.append(\"cancelled:\\(id):\\(sent):\\(total)\")\n            case .updated, .completed:\n                break\n            }\n        }\n\n        manager.start(id: transferID, totalFragments: 4)\n        manager.recordFragmentSent(id: transferID)\n        manager.cancel(id: transferID)\n\n        let didReceive = await TestHelpers.waitUntil({\n            recorder.values.contains(\"started:\\(transferID):4\") &&\n            recorder.values.contains(\"cancelled:\\(transferID):1:4\")\n        }, timeout: 0.5)\n        #expect(didReceive)\n\n        #expect(recorder.values.contains(\"started:\\(transferID):4\"))\n        #expect(recorder.values.contains(\"cancelled:\\(transferID):1:4\"))\n        #expect(manager.snapshot(id: transferID) == nil)\n        _ = cancellable\n    }\n}\n\nprivate final class EventRecorder: @unchecked Sendable {\n    private let lock = NSLock()\n    private var storage: [String] = []\n\n    var values: [String] {\n        lock.lock()\n        defer { lock.unlock() }\n        return storage\n    }\n\n    func append(_ value: String) {\n        lock.lock()\n        storage.append(value)\n        lock.unlock()\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/UnifiedPeerServiceTests.swift",
    "content": "//\n// UnifiedPeerServiceTests.swift\n// bitchatTests\n//\n// Tests for UnifiedPeerService fingerprint and block resolution.\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\nstruct UnifiedPeerServiceTests {\n\n    @Test @MainActor\n    func getFingerprint_prefersMeshService() async {\n        let transport = MockTransport()\n        let identity = TestIdentityManager()\n        let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())\n        let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)\n\n        let peerID = PeerID(str: \"00000000000000CC\")\n        transport.peerFingerprints[peerID] = \"fp-1\"\n\n        let fingerprint = service.getFingerprint(for: peerID)\n\n        #expect(fingerprint == \"fp-1\")\n    }\n\n    @Test @MainActor\n    func isBlocked_usesSocialIdentity() async {\n        let transport = MockTransport()\n        let identity = TestIdentityManager()\n        let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())\n        let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)\n\n        let peerID = PeerID(str: \"00000000000000DD\")\n        let fingerprint = \"fp-blocked\"\n        transport.peerFingerprints[peerID] = fingerprint\n        identity.setBlocked(fingerprint, isBlocked: true)\n\n        #expect(service.isBlocked(peerID))\n    }\n}\n\nprivate final class TestIdentityManager: SecureIdentityStateManagerProtocol {\n    private var socialIdentities: [String: SocialIdentity] = [:]\n    private var favorites: Set<String> = []\n    private var blockedNostr: Set<String> = []\n    private var verified: Set<String> = []\n\n    func forceSave() {}\n\n    func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {\n        socialIdentities[fingerprint]\n    }\n\n    func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}\n\n    func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {\n        []\n    }\n\n    func updateSocialIdentity(_ identity: SocialIdentity) {\n        socialIdentities[identity.fingerprint] = identity\n    }\n\n    func getFavorites() -> Set<String> {\n        favorites\n    }\n\n    func setFavorite(_ fingerprint: String, isFavorite: Bool) {\n        if isFavorite {\n            favorites.insert(fingerprint)\n        } else {\n            favorites.remove(fingerprint)\n        }\n    }\n\n    func isFavorite(fingerprint: String) -> Bool {\n        favorites.contains(fingerprint)\n    }\n\n    func isBlocked(fingerprint: String) -> Bool {\n        socialIdentities[fingerprint]?.isBlocked ?? false\n    }\n\n    func setBlocked(_ fingerprint: String, isBlocked: Bool) {\n        var identity = socialIdentities[fingerprint] ?? SocialIdentity(\n            fingerprint: fingerprint,\n            localPetname: nil,\n            claimedNickname: \"\",\n            trustLevel: .unknown,\n            isFavorite: false,\n            isBlocked: false,\n            notes: nil\n        )\n        identity.isBlocked = isBlocked\n        socialIdentities[fingerprint] = identity\n    }\n\n    func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {\n        blockedNostr.contains(pubkeyHexLowercased)\n    }\n\n    func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {\n        if isBlocked {\n            blockedNostr.insert(pubkeyHexLowercased)\n        } else {\n            blockedNostr.remove(pubkeyHexLowercased)\n        }\n    }\n\n    func getBlockedNostrPubkeys() -> Set<String> {\n        blockedNostr\n    }\n\n    func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}\n\n    func updateHandshakeState(peerID: PeerID, state: HandshakeState) {}\n\n    func clearAllIdentityData() {\n        socialIdentities.removeAll()\n        favorites.removeAll()\n        blockedNostr.removeAll()\n        verified.removeAll()\n    }\n\n    func removeEphemeralSession(peerID: PeerID) {}\n\n    func setVerified(fingerprint: String, verified: Bool) {\n        if verified {\n            self.verified.insert(fingerprint)\n        } else {\n            self.verified.remove(fingerprint)\n        }\n    }\n\n    func isVerified(fingerprint: String) -> Bool {\n        verified.contains(fingerprint)\n    }\n\n    func getVerifiedFingerprints() -> Set<String> {\n        verified\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Services/VerificationServiceTests.swift",
    "content": "import XCTest\n@testable import bitchat\n\nfinal class VerificationServiceTests: XCTestCase {\n    func test_buildMyQRString_roundTripsSuccessfully() throws {\n        let (service, noise) = makeService()\n        let nickname = \"alice-\\(UUID().uuidString)\"\n        let npub = \"npub1testvalue\"\n\n        let qrString = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: npub))\n        let parsed = try XCTUnwrap(service.verifyScannedQR(qrString))\n\n        XCTAssertEqual(parsed.nickname, nickname)\n        XCTAssertEqual(parsed.npub, npub)\n        XCTAssertEqual(parsed.noiseKeyHex, noise.getStaticPublicKeyData().hexEncodedString())\n        XCTAssertEqual(parsed.signKeyHex, noise.getSigningPublicKeyData().hexEncodedString())\n    }\n\n    func test_buildMyQRString_returnsCachedValueForSameInputs() throws {\n        let (service, _) = makeService()\n        let nickname = \"cache-\\(UUID().uuidString)\"\n\n        let first = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: nil))\n        let second = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: nil))\n\n        XCTAssertEqual(first, second)\n    }\n\n    func test_verifyScannedQR_rejectsExpiredPayload() throws {\n        let (service, noise) = makeService()\n        let oldTimestamp = Int64(Date().addingTimeInterval(-3600).timeIntervalSince1970)\n        let qrString = try makeSignedQR(\n            noise: noise,\n            nickname: \"expired-\\(UUID().uuidString)\",\n            npub: nil,\n            ts: oldTimestamp\n        )\n\n        XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60))\n    }\n\n    func test_verifyScannedQR_rejectsTamperedSignature() throws {\n        let (service, noise) = makeService()\n        let badSignature = Data(repeating: 0xAA, count: 64)\n        let qrString = try makeSignedQR(\n            noise: noise,\n            nickname: \"tampered-\\(UUID().uuidString)\",\n            npub: nil,\n            ts: Int64(Date().timeIntervalSince1970),\n            signatureOverride: badSignature\n        )\n\n        XCTAssertNil(service.verifyScannedQR(qrString))\n    }\n\n    func test_buildVerifyChallenge_roundTripsThroughNoisePayload() throws {\n        let (service, _) = makeService()\n        let noiseKeyHex = String(repeating: \"ab\", count: 32)\n        let nonce = Data([0x01, 0x02, 0x03, 0x04])\n\n        let encoded = service.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonce)\n        let payload = try XCTUnwrap(NoisePayload.decode(encoded))\n        let parsed = try XCTUnwrap(service.parseVerifyChallenge(payload.data))\n\n        XCTAssertEqual(payload.type, .verifyChallenge)\n        XCTAssertEqual(parsed.noiseKeyHex, noiseKeyHex)\n        XCTAssertEqual(parsed.nonceA, nonce)\n    }\n\n    func test_buildVerifyResponse_roundTripsAndVerifiesSignature() throws {\n        let (service, noise) = makeService()\n        let noiseKeyHex = String(repeating: \"cd\", count: 32)\n        let nonce = Data([0x10, 0x20, 0x30, 0x40, 0x50])\n\n        let encoded = try XCTUnwrap(service.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonce))\n        let payload = try XCTUnwrap(NoisePayload.decode(encoded))\n        let parsed = try XCTUnwrap(service.parseVerifyResponse(payload.data))\n\n        XCTAssertEqual(payload.type, .verifyResponse)\n        XCTAssertEqual(parsed.noiseKeyHex, noiseKeyHex)\n        XCTAssertEqual(parsed.nonceA, nonce)\n        XCTAssertTrue(\n            service.verifyResponseSignature(\n                noiseKeyHex: parsed.noiseKeyHex,\n                nonceA: parsed.nonceA,\n                signature: parsed.signature,\n                signerPublicKeyHex: noise.getSigningPublicKeyData().hexEncodedString()\n            )\n        )\n        XCTAssertFalse(\n            service.verifyResponseSignature(\n                noiseKeyHex: parsed.noiseKeyHex,\n                nonceA: Data([0xFF]),\n                signature: parsed.signature,\n                signerPublicKeyHex: noise.getSigningPublicKeyData().hexEncodedString()\n            )\n        )\n    }\n\n    private func makeService() -> (VerificationService, NoiseEncryptionService) {\n        let noise = NoiseEncryptionService(keychain: MockKeychain())\n        let service = VerificationService()\n        service.configure(with: noise)\n        return (service, noise)\n    }\n\n    private func makeSignedQR(\n        noise: NoiseEncryptionService,\n        nickname: String,\n        npub: String?,\n        ts: Int64,\n        signatureOverride: Data? = nil\n    ) throws -> String {\n        var payload = VerificationService.VerificationQR(\n            v: 1,\n            noiseKeyHex: noise.getStaticPublicKeyData().hexEncodedString(),\n            signKeyHex: noise.getSigningPublicKeyData().hexEncodedString(),\n            npub: npub,\n            nickname: nickname,\n            ts: ts,\n            nonceB64: Data((0..<16).map(UInt8.init)).base64EncodedString(),\n            sigHex: \"\"\n        )\n        let signature = try XCTUnwrap(signatureOverride ?? noise.signData(payload.canonicalBytes()))\n        payload.sigHex = signature.hexEncodedString()\n        return payload.toURLString()\n    }\n}\n"
  },
  {
    "path": "bitchatTests/SubscriptionRateLimitTests.swift",
    "content": "//\n// SubscriptionRateLimitTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\n/// Tests for BCH-01-004 fix: Rate-limiting subscription-triggered announces\n/// to prevent device enumeration attacks\nstruct SubscriptionRateLimitTests {\n\n    @Test(\"Rate limit configuration values are sensible\")\n    func rateLimitConfigurationValues() {\n        // Minimum interval should be at least 1 second to slow enumeration\n        #expect(TransportConfig.bleSubscriptionRateLimitMinSeconds >= 1.0)\n\n        // Backoff factor should be > 1 for exponential backoff\n        #expect(TransportConfig.bleSubscriptionRateLimitBackoffFactor > 1.0)\n\n        // Max backoff should be reasonable (not hours)\n        #expect(TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds <= 60.0)\n        #expect(TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds >= TransportConfig.bleSubscriptionRateLimitMinSeconds)\n\n        // Window should be long enough to track repeated attempts\n        #expect(TransportConfig.bleSubscriptionRateLimitWindowSeconds >= 30.0)\n\n        // Max attempts before suppression should be > 1 to allow legitimate reconnects\n        #expect(TransportConfig.bleSubscriptionRateLimitMaxAttempts >= 2)\n    }\n\n    @Test(\"Exponential backoff calculation is correct\")\n    func exponentialBackoffCalculation() {\n        let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds\n        let factor = TransportConfig.bleSubscriptionRateLimitBackoffFactor\n        let maxBackoff = TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds\n\n        // Simulate backoff progression\n        var currentBackoff = minInterval\n        var iterations = 0\n        let maxIterations = 10\n\n        while currentBackoff < maxBackoff && iterations < maxIterations {\n            let nextBackoff = min(currentBackoff * factor, maxBackoff)\n            #expect(nextBackoff >= currentBackoff, \"Backoff should increase or stay at max\")\n            currentBackoff = nextBackoff\n            iterations += 1\n        }\n\n        // Should reach max within reasonable iterations\n        #expect(iterations <= maxIterations, \"Backoff should reach max within \\(maxIterations) iterations\")\n        #expect(currentBackoff == maxBackoff, \"Final backoff should equal max\")\n    }\n\n    @Test(\"Rate limiting would significantly slow enumeration attacks\")\n    func rateLimitingSlowsEnumeration() {\n        // Without rate limiting: ~120 devices/minute (0.5 seconds per device)\n        // With rate limiting: minimum interval enforced\n\n        let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds\n        let devicesPerMinuteWithRateLimit = 60.0 / minInterval\n\n        // Should be significantly slower than 120 devices/minute\n        #expect(devicesPerMinuteWithRateLimit < 60, \"Rate limiting should significantly slow enumeration\")\n\n        // With 2-second minimum interval, max ~30 devices/minute per connection\n        // And with backoff, repeated attempts are even slower\n        #expect(devicesPerMinuteWithRateLimit <= 30, \"With 2s minimum, should be <=30/min\")\n    }\n\n    @Test(\"Max attempts threshold prevents complete enumeration\")\n    func maxAttemptsThresholdPreventsEnumeration() {\n        let maxAttempts = TransportConfig.bleSubscriptionRateLimitMaxAttempts\n\n        // After max attempts within window, announces are suppressed entirely\n        // This means an attacker gets at most maxAttempts announces per window\n        #expect(maxAttempts >= 2, \"Should allow at least 2 attempts for legitimate reconnects\")\n        #expect(maxAttempts <= 10, \"Should cap attempts to prevent enumeration\")\n\n        // With 5 attempts max and 2s minimum interval, attacker gets limited info\n        let maxAnnounces = maxAttempts\n        #expect(maxAnnounces <= 10, \"Max announces per window should be limited\")\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Sync/RequestSyncManagerTests.swift",
    "content": "import XCTest\n@testable import bitchat\n\nfinal class RequestSyncManagerTests: XCTestCase {\n    func test_isValidResponse_returnsFalseWhenPacketIsNotRSR() {\n        let clock = MutableSyncClock(now: 1_000)\n        let manager = RequestSyncManager(responseWindow: 30, now: { clock.now })\n\n        manager.registerRequest(to: PeerID(str: \"aaaaaaaaaaaaaaaa\"))\n        XCTAssertFalse(manager.isValidResponse(from: PeerID(str: \"aaaaaaaaaaaaaaaa\"), isRSR: false))\n    }\n\n    func test_isValidResponse_returnsFalseForUnsolicitedRSR() {\n        let clock = MutableSyncClock(now: 1_000)\n        let manager = RequestSyncManager(responseWindow: 30, now: { clock.now })\n\n        XCTAssertFalse(manager.isValidResponse(from: PeerID(str: \"bbbbbbbbbbbbbbbb\"), isRSR: true))\n    }\n\n    func test_isValidResponse_acceptsRecentRequest() async {\n        let clock = MutableSyncClock(now: 1_000)\n        let manager = RequestSyncManager(responseWindow: 30, now: { clock.now })\n        let peerID = PeerID(str: \"cccccccccccccccc\")\n\n        manager.registerRequest(to: peerID)\n        let registered = await waitUntil {\n            manager.debugPendingRequestCount == 1\n        }\n        XCTAssertTrue(registered)\n\n        clock.now = 1_020\n        XCTAssertTrue(manager.isValidResponse(from: peerID, isRSR: true))\n    }\n\n    func test_cleanup_removesExpiredRequestsAndPreservesFreshOnes() async {\n        let clock = MutableSyncClock(now: 1_000)\n        let manager = RequestSyncManager(responseWindow: 30, now: { clock.now })\n        let expiredPeer = PeerID(str: \"dddddddddddddddd\")\n        let freshPeer = PeerID(str: \"eeeeeeeeeeeeeeee\")\n\n        manager.registerRequest(to: expiredPeer)\n        _ = await waitUntil { manager.debugPendingRequestCount == 1 }\n\n        clock.now = 1_010\n        manager.registerRequest(to: freshPeer)\n        let bothRegistered = await waitUntil {\n            manager.debugPendingRequestCount == 2\n        }\n        XCTAssertTrue(bothRegistered)\n\n        clock.now = 1_035\n        XCTAssertFalse(manager.isValidResponse(from: expiredPeer, isRSR: true))\n        XCTAssertTrue(manager.isValidResponse(from: freshPeer, isRSR: true))\n\n        manager.cleanup()\n        let cleaned = await waitUntil {\n            manager.debugPendingRequestCount == 1\n        }\n        XCTAssertTrue(cleaned)\n        XCTAssertFalse(manager.isValidResponse(from: expiredPeer, isRSR: true))\n        XCTAssertTrue(manager.isValidResponse(from: freshPeer, isRSR: true))\n    }\n\n    private func waitUntil(\n        timeout: TimeInterval = 1.0,\n        condition: @escaping () -> Bool\n    ) async -> Bool {\n        let deadline = Date().addingTimeInterval(timeout)\n        while Date() < deadline {\n            if condition() {\n                return true\n            }\n            try? await Task.sleep(nanoseconds: 10_000_000)\n        }\n        return condition()\n    }\n}\n\nprivate final class MutableSyncClock {\n    var now: TimeInterval\n\n    init(now: TimeInterval) {\n        self.now = now\n    }\n}\n"
  },
  {
    "path": "bitchatTests/TestUtilities/TestConstants.swift",
    "content": "//\n// TestConstants.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n@testable import bitchat\n\nstruct TestConstants {\n    static let defaultTimeout: TimeInterval = 5.0\n    static let shortTimeout: TimeInterval = 1.0\n    static let longTimeout: TimeInterval = 10.0\n    \n    static let testNickname1 = \"Alice\"\n    static let testNickname2 = \"Bob\"\n    static let testNickname3 = \"Charlie\"\n    static let testNickname4 = \"David\"\n    \n    static let testMessage1 = \"Hello, World!\"\n    static let testMessage2 = \"How are you?\"\n    static let testMessage3 = \"This is a test message\"\n    static let testLongMessage = String(repeating: \"This is a long message. \", count: 100)\n    \n    static let testSignature = Data(repeating: 0xAB, count: 64)\n}\n"
  },
  {
    "path": "bitchatTests/TestUtilities/TestHelpers.swift",
    "content": "//\n// TestHelpers.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\nimport CryptoKit\n@testable import bitchat\n\nfinal class TestHelpers {\n    \n    // MARK: - Key Generation\n    \n    static func generateTestKeyPair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) {\n        let privateKey = Curve25519.KeyAgreement.PrivateKey()\n        let publicKey = privateKey.publicKey\n        return (privateKey, publicKey)\n    }\n    \n    static func generateTestIdentity(peerID: String, nickname: String) -> (peerID: String, nickname: String, privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) {\n        let (privateKey, publicKey) = generateTestKeyPair()\n        return (peerID: peerID, nickname: nickname, privateKey: privateKey, publicKey: publicKey)\n    }\n    \n    // MARK: - Message Creation\n    \n    static func createTestMessage(\n        content: String = TestConstants.testMessage1,\n        sender: String = TestConstants.testNickname1,\n        senderPeerID: PeerID = PeerID(str: UUID().uuidString),\n        isPrivate: Bool = false,\n        recipientNickname: String? = nil,\n        mentions: [String]? = nil\n    ) -> BitchatMessage {\n        return BitchatMessage(\n            id: UUID().uuidString,\n            sender: sender,\n            content: content,\n            timestamp: Date(),\n            isRelay: false,\n            originalSender: nil,\n            isPrivate: isPrivate,\n            recipientNickname: recipientNickname,\n            senderPeerID: senderPeerID,\n            mentions: mentions\n        )\n    }\n    \n    static func createTestPacket(\n        type: UInt8 = 0x01,\n        senderID: PeerID = PeerID(str: UUID().uuidString),\n        recipientID: PeerID? = nil,\n        payload: Data = \"test payload\".data(using: .utf8)!,\n        signature: Data? = nil,\n        ttl: UInt8 = 3\n    ) -> BitchatPacket {\n        return BitchatPacket(\n            type: type,\n            senderID: senderID.id.data(using: .utf8)!,\n            recipientID: recipientID?.id.data(using: .utf8),\n            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),\n            payload: payload,\n            signature: signature,\n            ttl: ttl\n        )\n    }\n    \n    // MARK: - Data Generation\n    \n    static func generateRandomData(length: Int) -> Data {\n        var data = Data(count: length)\n        _ = data.withUnsafeMutableBytes { bytes in\n            SecRandomCopyBytes(kSecRandomDefault, length, bytes.baseAddress!)\n        }\n        return data\n    }\n    \n    static func generateTestPeerID() -> String {\n        return \"PEER\" + UUID().uuidString.prefix(8)\n    }\n    \n    // MARK: - Async Helpers\n    \n    static func waitFor(_ condition: @escaping () -> Bool, timeout: TimeInterval = TestConstants.defaultTimeout) async throws {\n        let start = Date()\n        while !condition() {\n            if Date().timeIntervalSince(start) > timeout {\n                throw TestError.timeout\n            }\n            try await sleep(0.01)\n        }\n    }\n\n    @MainActor\n    static func waitUntil(\n        _ condition: @escaping () -> Bool,\n        timeout: TimeInterval = TestConstants.defaultTimeout,\n        pollInterval: TimeInterval = 0.01\n    ) async -> Bool {\n        let start = Date()\n        while !condition() {\n            if Date().timeIntervalSince(start) > timeout {\n                return condition()\n            }\n            try? await sleep(pollInterval)\n        }\n        return true\n    }\n    \n    static func expectAsync<T>(\n        timeout: TimeInterval = TestConstants.defaultTimeout,\n        operation: @escaping () async throws -> T\n    ) async throws -> T {\n        return try await withThrowingTaskGroup(of: T.self) { group in\n            group.addTask {\n                return try await operation()\n            }\n            \n            group.addTask {\n                try await sleep(1)\n                throw TestError.timeout\n            }\n            \n            let result = try await group.next()!\n            group.cancelAll()\n            return result\n        }\n    }\n}\n\nenum TestError: Error {\n    case timeout\n    case unexpectedValue\n    case testFailure(String)\n}\n\nfunc sleep(_ seconds: TimeInterval) async throws {\n    try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))\n}\n"
  },
  {
    "path": "bitchatTests/Utils/HexStringTests.swift",
    "content": "//\n// HexStringTests.swift\n// bitchatTests\n//\n// Tests for Data(hexString:) hex parsing\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\nstruct HexStringTests {\n\n    // MARK: - Valid Hex Strings\n\n    @Test func validHexString() {\n        let data = Data(hexString: \"0102030405\")\n        #expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05]))\n    }\n\n    @Test func validHexStringUppercase() {\n        let data = Data(hexString: \"AABBCCDD\")\n        #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD]))\n    }\n\n    @Test func validHexStringMixedCase() {\n        let data = Data(hexString: \"aAbBcCdD\")\n        #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD]))\n    }\n\n    @Test func validHexStringWith0xPrefix() {\n        let data = Data(hexString: \"0x0102030405\")\n        #expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05]))\n    }\n\n    @Test func validHexStringWith0XPrefix() {\n        let data = Data(hexString: \"0XAABBCCDD\")\n        #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD]))\n    }\n\n    @Test func validHexStringWithWhitespace() {\n        let data = Data(hexString: \"  0102030405  \")\n        #expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05]))\n    }\n\n    @Test func validHexStringWith0xPrefixAndWhitespace() {\n        let data = Data(hexString: \"  0x0102030405  \")\n        #expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05]))\n    }\n\n    @Test func emptyHexString() {\n        let data = Data(hexString: \"\")\n        #expect(data == Data())\n    }\n\n    @Test func emptyHexStringWithWhitespace() {\n        let data = Data(hexString: \"   \")\n        #expect(data == Data())\n    }\n\n    @Test func emptyHexStringWith0xPrefix() {\n        let data = Data(hexString: \"0x\")\n        #expect(data == Data())\n    }\n\n    // MARK: - Invalid Hex Strings\n\n    @Test func oddLengthHexStringReturnsNil() {\n        let data = Data(hexString: \"012\")\n        #expect(data == nil)\n    }\n\n    @Test func oddLengthHexStringWith0xPrefixReturnsNil() {\n        let data = Data(hexString: \"0x012\")\n        #expect(data == nil)\n    }\n\n    @Test func invalidCharactersReturnNil() {\n        let data = Data(hexString: \"GHIJ\")\n        #expect(data == nil)\n    }\n\n    @Test func mixedValidAndInvalidCharactersReturnNil() {\n        let data = Data(hexString: \"01GH\")\n        #expect(data == nil)\n    }\n\n    @Test func specialCharactersReturnNil() {\n        let data = Data(hexString: \"01-02\")\n        #expect(data == nil)\n    }\n\n    // MARK: - Round Trip Tests\n\n    @Test func roundTripConversion() {\n        let original = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])\n        let hexString = original.hexEncodedString()\n        let roundTripped = Data(hexString: hexString)\n        #expect(roundTripped == original)\n    }\n\n    @Test func roundTripConversionWith0xPrefix() {\n        let original = Data([0xDE, 0xAD, 0xBE, 0xEF])\n        let hexString = \"0x\" + original.hexEncodedString()\n        let roundTripped = Data(hexString: hexString)\n        #expect(roundTripped == original)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/Utils/PeerIDTests.swift",
    "content": "//\n// PeerIDTests.swift\n// bitchatTests\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Testing\nimport Foundation\n@testable import bitchat\n\nstruct PeerIDTests {\n    private let hex16 = \"0011223344556677\"\n    private let hex64 = \"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff\"\n    \n    private let encoder: JSONEncoder = {\n        var encoder = JSONEncoder()\n        encoder.outputFormatting = [.sortedKeys]\n        return encoder\n    }()\n    \n    // MARK: - Empty prefix\n    \n    @Test func empty_prefix_with16() {\n        let peerID = PeerID(str: hex16)\n        #expect(peerID.id == hex16)\n        #expect(peerID.bare == hex16)\n        #expect(peerID.prefix == .empty)\n    }\n    \n    @Test func empty_prefix_with64() {\n        let peerID = PeerID(str: hex64)\n        #expect(peerID.id == hex64)\n        #expect(peerID.bare == hex64)\n        #expect(peerID.prefix == .empty)\n    }\n    \n    // MARK: - Mesh prefix\n    \n    @Test func mesh_prefix_with16() {\n        let str = \"mesh:\" + hex16\n        let peerID = PeerID(str: str)\n        #expect(peerID.id == str)\n        #expect(peerID.bare == hex16)\n        #expect(peerID.prefix == .mesh)\n    }\n    \n    @Test func mesh_prefix_with64() {\n        let str = \"mesh:\" + hex64\n        let peerID = PeerID(str: str)\n        #expect(peerID.id == str)\n        #expect(peerID.bare == hex64)\n        #expect(peerID.prefix == .mesh)\n    }\n    \n    // MARK: - Name prefix\n    \n    @Test func name_prefix() {\n        let str = \"name:some_name\"\n        let peerID = PeerID(str: str)\n        #expect(peerID.id == str)\n        #expect(peerID.bare == \"some_name\")\n        #expect(peerID.prefix == .name)\n    }\n    \n    // MARK: - Noise prefix\n    \n    @Test func noise_prefix_with16() {\n        let str = \"noise:\" + hex16\n        let peerID = PeerID(str: str)\n        #expect(peerID.id == str)\n        #expect(peerID.bare == hex16)\n        #expect(peerID.prefix == .noise)\n    }\n    \n    @Test func noise_prefix_with64() {\n        let str = \"noise:\" + hex64\n        let peerID = PeerID(str: str)\n        #expect(peerID.id == str)\n        #expect(peerID.bare == hex64)\n        #expect(peerID.prefix == .noise)\n    }\n    \n    // MARK: - GeoDM prefix\n    \n    @Test func geoDM_prefix_with16() {\n        let str = \"nostr_\" + hex16\n        let peerID = PeerID(str: str)\n        #expect(peerID.id == str)\n        #expect(peerID.bare == hex16)\n        #expect(peerID.prefix == .geoDM)\n    }\n    \n    @Test func geoDM_prefix_with64() {\n        let str = \"nostr_\" + hex64\n        let peerID = PeerID(str: str)\n        #expect(peerID.id == str)\n        #expect(peerID.bare == hex64)\n        #expect(peerID.prefix == .geoDM)\n    }\n    \n    // MARK: - GeoChat prefix\n    \n    @Test func geoChat_prefix_with16() {\n        let str = \"nostr:\" + hex16\n        let peerID = PeerID(str: str)\n        #expect(peerID.id == str)\n        #expect(peerID.bare == hex16)\n        #expect(peerID.prefix == .geoChat)\n    }\n    \n    @Test func geoChat_prefix_with64() {\n        let str = \"nostr:\" + hex64\n        let peerID = PeerID(str: str)\n        #expect(peerID.id == str)\n        #expect(peerID.bare == hex64)\n        #expect(peerID.prefix == .geoChat)\n    }\n    \n    // MARK: - Edge cases\n    \n    @Test func with_unknown_prefix() {\n        let str = \"unknown:\" + hex16\n        let peerID = PeerID(str: str)\n        // Falls back to .empty\n        #expect(peerID.id == str)\n        #expect(peerID.bare == str)\n        #expect(peerID.prefix == .empty)\n    }\n    \n    @Test func with_only_prefix_no_bare() {\n        let str = \"mesh:\"\n        let peerID = PeerID(str: str)\n        #expect(peerID.id == str)\n        #expect(peerID.bare == \"\")\n        #expect(peerID.prefix == .mesh)\n    }\n    \n    // MARK: - init?(data:)\n    \n    @Test func data_valid_utf8() {\n        let peerID = PeerID(data: Data(hex16.utf8))\n        #expect(peerID != nil)\n        #expect(peerID?.bare == hex16)\n        #expect(peerID?.prefix == .empty)\n    }\n    \n    @Test func data_invalid_utf8() {\n        // Random invalid UTF8\n        let bytes: [UInt8] = [0xFF, 0xFE, 0xFA]\n        let peerID = PeerID(data: Data(bytes))\n        #expect(peerID == nil)\n    }\n    \n    // MARK: - init(str: Substring)\n    \n    @Test func substring() {\n        let substring = hex64.prefix(16)\n        let peerID = PeerID(str: substring)\n        #expect(peerID.id == String(substring))\n        #expect(peerID.bare == String(substring))\n        #expect(peerID.prefix == .empty)\n    }\n    \n    // MARK: - init(nostr_ pubKey:)\n    \n    @Test func nostrUnderscore_pubKey() {\n        let pubKey = hex64\n        let peerID = PeerID(nostr_: pubKey)\n        #expect(peerID.id == \"nostr_\\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))\")\n        #expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength)))\n        #expect(peerID.prefix == .geoDM)\n    }\n    \n    // MARK: - init(nostr pubKey:)\n    \n    @Test func nostr_pubKey() {\n        let pubKey = hex64\n        let peerID = PeerID(nostr: pubKey)\n        #expect(peerID.id == \"nostr:\\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))\")\n        #expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength)))\n        #expect(peerID.prefix == .geoChat)\n    }\n    \n    // MARK: - init(publicKey:)\n    \n    @Test func publicKey_derivesFingerprint() {\n        let publicKey = Data(hex64.utf8)\n        let expected = publicKey.sha256Fingerprint().prefix(16)\n        let peerID = PeerID(publicKey: publicKey)\n        #expect(peerID.bare == String(expected))\n        #expect(peerID.prefix == .empty)\n    }\n    \n    // MARK: - toShort()\n    \n    @Test func toShort_whenNoiseKeyExists() {\n        let peerID = PeerID(str: hex64)\n        let short = peerID.toShort()\n        let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16)\n        #expect(short.bare == String(expected))\n        #expect(short.prefix == .empty)\n    }\n    \n    @Test func toShort_whenNoiseKeyExists_withNoisePrefix() {\n        let peerID = PeerID(str: \"noise:\" + hex64)\n        let short = peerID.toShort()\n        let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16)\n        #expect(short.bare == String(expected))\n        #expect(short.prefix == .empty)\n        #expect(peerID.prefix == .noise)\n    }\n    \n    @Test func toShort_whenNoNoiseKey() {\n        let peerID = PeerID(str: \"some_random_key\")\n        let short = peerID.toShort()\n        #expect(short == peerID)\n    }\n\n    @Test func routingData_fromShortID() throws {\n        let peerID = PeerID(str: hex16)\n        let routing = try #require(peerID.routingData)\n        #expect(routing.count == 8)\n        #expect(routing == Data(hexString: hex16))\n    }\n\n    @Test func routingData_fromNoiseKey() throws {\n        let peerID = PeerID(str: hex64)\n        let routing = try #require(peerID.routingData)\n        let expectedShort = peerID.toShort()\n        #expect(routing == Data(hexString: expectedShort.id))\n    }\n\n    @Test func routingPeerRoundTrip() throws {\n        let raw = try #require(Data(hexString: hex16))\n        let peerID = try #require(PeerID(routingData: raw))\n        #expect(peerID.routingData == raw)\n    }\n\n    // MARK: - Codable\n\n    @Test func codable_emptyPrefix() throws {\n        struct Dummy: Codable, Equatable {\n            let name: String\n            let peerID: PeerID\n        }\n        \n        let str = \"aabbccddeeff0011\"\n        let jsonString = \"{\\\"name\\\":\\\"some name\\\",\\\"peerID\\\":\\\"\\(str)\\\"}\"\n        \n        let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8))\n        #expect(decoded.peerID == PeerID(str: str))\n        \n        let encoded = try encoder.encode(decoded)\n        #expect(String(data: encoded, encoding: .utf8) == jsonString)\n    }\n\n    @Test func codable_withPrefix() throws {\n        struct Dummy: Codable, Equatable {\n            let peerID: PeerID\n        }\n        \n        let str = \"nostr_\\(hex16)\"\n        let jsonString = \"{\\\"peerID\\\":\\\"\\(str)\\\"}\"\n        \n        let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8))\n        #expect(decoded.peerID == PeerID(str: str))\n        #expect(decoded.peerID.bare == hex16)\n        #expect(decoded.peerID.prefix == .geoDM)\n        \n        let encoded = try encoder.encode(decoded)\n        #expect(String(data: encoded, encoding: .utf8) == jsonString)\n    }\n\n    @Test func codable_multiplePrefixes() throws {\n        // Loop across all Prefix cases (except .empty since already tested)\n        for prefix in PeerID.Prefix.allCases where prefix != .empty {\n            let bare = hex16\n            let str = prefix.rawValue + bare\n            \n            let decoded = try JSONDecoder().decode(PeerID.self, from: Data(\"\\\"\\(str)\\\"\".utf8))\n            #expect(decoded.prefix == prefix)\n            #expect(decoded.bare == bare)\n            \n            let encoded = try encoder.encode(decoded)\n            #expect(String(data: encoded, encoding: .utf8) == \"\\\"\\(str)\\\"\")\n        }\n    }\n    \n    // MARK: - Comparable\n    \n    @Test func comparable_sorting_and_equality() {\n        let p1 = PeerID(str: \"aaa\")\n        let p2 = PeerID(str: \"bbb\")\n        let p3 = PeerID(str: \"BBB\")\n        \n        #expect(p1 < p2)\n        #expect(p2 >= p1)\n        #expect(p2 == p3)\n        \n        let sorted = [p2, p1].sorted()\n        #expect(sorted == [p1, p2])\n    }\n    \n    @Test func equality() {\n        let peerID = PeerID(str: \"aaa\")\n        \n        // Regular PeerID <> PeerID\n        #expect(peerID == PeerID(str: \"AAA\"))\n        #expect(peerID == Optional(PeerID(str: \"AAA\")))\n        #expect(PeerID(str: \"AAA\") == peerID)\n        #expect(Optional(PeerID(str: \"AAA\")) == Optional(peerID))\n        \n        #expect(peerID != PeerID(str: \"BBB\"))\n        #expect(peerID != Optional(PeerID(str: \"BBB\")))\n        #expect(PeerID(str: \"BBB\") != peerID)\n        #expect(Optional(PeerID(str: \"BBB\")) != Optional(peerID))\n    }\n    \n    // MARK: - Computed properties\n    \n    @Test func isEmpty_true_and_false() {\n        #expect(PeerID(str: \"\").isEmpty)\n        #expect(!PeerID(str: \"abc\").isEmpty)\n    }\n    \n    @Test func isGeoChat() {\n        #expect(PeerID(str: \"nostr:abcdef\").isGeoChat)\n        #expect(!PeerID(str: \"nostr_abcdef\").isGeoChat)\n    }\n    \n    @Test func isGeoDM() {\n        #expect(PeerID(str: \"nostr_abcdef\").isGeoDM)\n        #expect(!PeerID(str: \"nostr:abcdef\").isGeoDM)\n    }\n    \n    @Test func toPercentEncoded() {\n        let peerID = PeerID(str: \"name:some value/with spaces?\")\n        let encoded = peerID.toPercentEncoded()\n        // spaces and ? should be percent-encoded in urlPathAllowed\n        #expect(encoded == \"name%3Asome%20value/with%20spaces%3F\")\n    }\n    \n    // MARK: - Validation\n    \n    @Test func accepts_short_hex_peer_id() {\n        #expect(PeerID(str: \"0011223344556677\").isValid)\n        #expect(PeerID(str: \"aabbccddeeff0011\").isValid)\n    }\n    \n    @Test func accepts_full_noise_key_hex() {\n        let hex64 = String(repeating: \"ab\", count: 32) // 64 hex chars\n        #expect(PeerID(str: hex64).isValid)\n    }\n    \n    @Test func accepts_internal_alnum_dash_underscore() {\n        #expect(PeerID(str: \"peer_123-ABC\").isValid)\n        #expect(PeerID(str: \"nostr_user_01\").isValid)\n    }\n    \n    @Test func rejects_invalid_characters() {\n        #expect(!PeerID(str: \"peer!@#\").isValid)\n        #expect(!PeerID(str: \"gggggggggggggggg\").isValid) // not hex for short form\n    }\n    \n    @Test func rejects_too_long() {\n        let tooLong = String(repeating: \"a\", count: 65)\n        #expect(!PeerID(str: tooLong).isValid)\n    }\n    \n    @Test func isShort() {\n        #expect(PeerID(str: hex16).isShort)\n        #expect(!PeerID(str: \"abcd\").isShort) // wrong length\n    }\n    \n    @Test func isNoiseKeyHex_and_noiseKey() {\n        let hex64 = String(repeating: \"ab\", count: 32) // 64 chars valid hex\n        let peerID = PeerID(str: hex64)\n        #expect(peerID.isNoiseKeyHex)\n        #expect(peerID.noiseKey != nil)\n        \n        let prefixedPeerID = PeerID(str: \"noise:\" + hex64)\n        #expect(prefixedPeerID.isNoiseKeyHex)\n        #expect(prefixedPeerID.noiseKey != nil)\n        \n        let bad = String(repeating: \"z\", count: 64) // invalid hex\n        let badPeerID = PeerID(str: bad)\n        #expect(!badPeerID.isNoiseKeyHex)\n        #expect(badPeerID.noiseKey == nil)\n    }\n    \n    @Test func prefixes() {\n        let hex64 = String(repeating: \"a\", count: 64)\n        #expect(PeerID(str: \"noise:\\(hex64)\").isValid)\n        #expect(PeerID(str: \"nostr:\\(hex64)\").isValid)\n        #expect(PeerID(str: \"nostr_\\(hex64)\").isValid)\n\n        let hex63 = String(repeating: \"a\", count: 63)\n        #expect(PeerID(str: \"noise:\\(hex63)\").isValid)\n        #expect(PeerID(str: \"nostr:\\(hex63)\").isValid)\n        #expect(PeerID(str: \"nostr_\\(hex63)\").isValid)\n\n        let hex16 = String(repeating: \"a\", count: 16)\n        #expect(PeerID(str: \"noise:\\(hex16)\").isValid)\n        #expect(PeerID(str: \"nostr:\\(hex16)\").isValid)\n        #expect(PeerID(str: \"nostr_\\(hex16)\").isValid)\n\n        let hex8 = String(repeating: \"a\", count: 8)\n        #expect(PeerID(str: \"noise:\\(hex8)\").isValid)\n        #expect(PeerID(str: \"nostr:\\(hex8)\").isValid)\n        #expect(PeerID(str: \"nostr_\\(hex8)\").isValid)\n\n        let mesh = \"mesh:abcdefg\"\n        #expect(PeerID(str: \"name:\\(mesh)\").isValid)\n\n        let name = \"name:some_name\"\n        #expect(PeerID(str: \"name:\\(name)\").isValid)\n\n        let badName = \"name:bad:name\"\n        #expect(!PeerID(str: \"name:\\(badName)\").isValid)\n\n        // Too long\n        let hex65 = String(repeating: \"a\", count: 65)\n        #expect(!PeerID(str: \"noise:\\(hex65)\").isValid)\n        #expect(!PeerID(str: \"nostr:\\(hex65)\").isValid)\n        #expect(!PeerID(str: \"nostr_\\(hex65)\").isValid)\n    }\n\n    // MARK: - File Transfer PeerID Normalization\n    // These tests verify the fix for asymmetric voice/media delivery (BCH-01-XXX)\n    // The bug occurred when selectedPrivateChatPeer was migrated to 64-hex stable key\n    // but the receiver expected SHA256-derived 16-hex format\n\n    @Test func fileTransfer_toShortNormalizesNoiseKeyToFingerprint() {\n        // Given: A 64-hex Noise public key (what selectedPrivateChatPeer becomes after session)\n        let noiseKey = Data(repeating: 0xAB, count: 32)\n        let stableKeyPeerID = PeerID(hexData: noiseKey)  // 64-hex\n\n        // When: Convert to short form (what sendFilePrivate should do)\n        let shortID = stableKeyPeerID.toShort()\n\n        // Then: Should be 16-hex SHA256 fingerprint (matching myPeerID format)\n        let expected = noiseKey.sha256Fingerprint().prefix(16)\n        #expect(shortID.id == String(expected))\n        #expect(shortID.id.count == 16)\n    }\n\n    @Test func fileTransfer_shortIDMatchesMyPeerIDFormat() {\n        // Given: A receiver's myPeerID is SHA256-derived (from refreshPeerIdentity)\n        let noiseKey = Data(repeating: 0xCD, count: 32)\n        let myPeerID = PeerID(publicKey: noiseKey)  // SHA256-derived 16-hex\n\n        // When: Sender uses toShort() on 64-hex stable key\n        let senderStableKey = PeerID(hexData: noiseKey)  // 64-hex\n        let recipientData = Data(hexString: senderStableKey.toShort().id)!\n        let receivedRecipientID = PeerID(hexData: recipientData)\n\n        // Then: Should match receiver's myPeerID (file transfer accepted)\n        #expect(receivedRecipientID == myPeerID)\n    }\n\n    @Test func fileTransfer_truncatedRawKeyDoesNotMatchMyPeerID() {\n        // This test demonstrates the bug we fixed\n        // When 64-hex was truncated to first 8 bytes instead of using SHA256 fingerprint\n\n        // Given: Receiver's myPeerID is SHA256-derived\n        let noiseKey = Data(repeating: 0xEF, count: 32)\n        let myPeerID = PeerID(publicKey: noiseKey)  // SHA256-derived 16-hex\n\n        // When: Truncate raw key (the OLD buggy behavior)\n        let truncatedRaw = noiseKey.prefix(8)  // First 8 bytes of raw key\n        let wrongRecipientID = PeerID(hexData: truncatedRaw)\n\n        // Then: Should NOT match (demonstrates why fix was needed)\n        #expect(wrongRecipientID != myPeerID)\n    }\n\n    @Test func fileTransfer_shortIDProducesCorrect8ByteRoutingData() {\n        // Verify the wire format is correct (8 bytes for BinaryProtocol)\n        let noiseKey = Data(repeating: 0x12, count: 32)\n        let stableKeyPeerID = PeerID(hexData: noiseKey)\n        let shortID = stableKeyPeerID.toShort()\n\n        // routingData should be 8 bytes (16 hex chars -> 8 bytes)\n        let routingData = shortID.routingData\n        #expect(routingData != nil)\n        #expect(routingData?.count == 8)\n\n        // And it should match SHA256 fingerprint first 8 bytes\n        let expectedFingerprint = noiseKey.sha256Fingerprint()\n        let expectedFirst8 = Data(hexString: String(expectedFingerprint.prefix(16)))\n        #expect(routingData == expectedFirst8)\n    }\n}\n"
  },
  {
    "path": "bitchatTests/ViewSmokeTests.swift",
    "content": "import Testing\nimport Foundation\nimport SwiftUI\nimport CoreGraphics\nimport AVFoundation\n#if os(iOS)\nimport UIKit\n#else\nimport AppKit\n#endif\n@testable import bitchat\n\n@MainActor\nprivate func makeSmokeViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identityManager: MockIdentityManager) {\n    let keychain = MockKeychain()\n    let keychainHelper = MockKeychainHelper()\n    let idBridge = NostrIdentityBridge(keychain: keychainHelper)\n    let identityManager = MockIdentityManager(keychain)\n    let transport = MockTransport()\n\n    let viewModel = ChatViewModel(\n        keychain: keychain,\n        idBridge: idBridge,\n        identityManager: identityManager,\n        transport: transport\n    )\n\n    return (viewModel, transport, identityManager)\n}\n\n@MainActor\n@discardableResult\nprivate func mount<V: View>(_ view: V) -> AnyObject {\n    #if os(iOS)\n    let host = UIHostingController(rootView: view)\n    _ = host.view\n    host.view.setNeedsLayout()\n    host.view.layoutIfNeeded()\n    return host\n    #else\n    let host = NSHostingView(rootView: view)\n    host.layoutSubtreeIfNeeded()\n    _ = host.fittingSize\n    return host\n    #endif\n}\n\nprivate func makeSnapshot(\n    peerID: PeerID,\n    nickname: String,\n    connected: Bool = true,\n    noiseByte: UInt8\n) -> TransportPeerSnapshot {\n    TransportPeerSnapshot(\n        peerID: peerID,\n        nickname: nickname,\n        isConnected: connected,\n        noisePublicKey: Data(repeating: noiseByte, count: 32),\n        lastSeen: Date()\n    )\n}\n\nprivate func makeCGImage() throws -> CGImage {\n    let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()\n    let context = try #require(\n        CGContext(\n            data: nil,\n            width: 8,\n            height: 8,\n            bitsPerComponent: 8,\n            bytesPerRow: 0,\n            space: colorSpace,\n            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue\n        )\n    )\n    context.setFillColor(CGColor(red: 0.1, green: 0.7, blue: 0.2, alpha: 1))\n    context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))\n    return try #require(context.makeImage())\n}\n\nprivate func makeTemporaryAudioURL() throws -> URL {\n    let url = FileManager.default.temporaryDirectory\n        .appendingPathComponent(UUID().uuidString)\n        .appendingPathExtension(\"caf\")\n    let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1))\n    let frameCount: AVAudioFrameCount = 1_600\n    let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount))\n    buffer.frameLength = frameCount\n    let channel = try #require(buffer.floatChannelData?[0])\n    for index in 0..<Int(frameCount) {\n        channel[index] = sinf(Float(index) * 0.2) * 0.5\n    }\n\n    let file = try AVAudioFile(forWriting: url, settings: format.settings)\n    try file.write(from: buffer)\n    return url\n}\n\nprivate func makeTemporaryImageURL() throws -> URL {\n    let url = FileManager.default.temporaryDirectory\n        .appendingPathComponent(UUID().uuidString)\n        .appendingPathExtension(\"png\")\n    let image = try makeCGImage()\n    #if os(iOS)\n    let data = try #require(UIImage(cgImage: image).pngData())\n    #else\n    let rep = NSBitmapImageRep(cgImage: image)\n    let data = try #require(rep.representation(using: .png, properties: [:]))\n    #endif\n    try data.write(to: url)\n    return url\n}\n\n@MainActor\nstruct ViewSmokeTests {\n    @Test\n    func fingerprintView_renders_verifiedAndPendingStates() async {\n        let (viewModel, transport, _) = makeSmokeViewModel()\n        let verifiedPeer = PeerID(str: \"0102030405060708\")\n        let pendingPeer = PeerID(str: \"1112131415161718\")\n        let verifiedFingerprint = String(repeating: \"ab\", count: 32)\n\n        transport.peerFingerprints[verifiedPeer] = verifiedFingerprint\n        transport.peerFingerprints[pendingPeer] = nil\n        transport.updatePeerSnapshots([\n            makeSnapshot(peerID: verifiedPeer, nickname: \"Alice\", noiseByte: 0x11),\n            makeSnapshot(peerID: pendingPeer, nickname: \"Bob\", noiseByte: 0x22)\n        ])\n        try? await Task.sleep(nanoseconds: 50_000_000)\n\n        viewModel.verifiedFingerprints.insert(verifiedFingerprint)\n\n        let verifiedView = FingerprintView(viewModel: viewModel, peerID: verifiedPeer)\n        let pendingView = FingerprintView(viewModel: viewModel, peerID: pendingPeer)\n\n        _ = verifiedView.body\n        _ = pendingView.body\n        _ = mount(verifiedView)\n        _ = mount(pendingView)\n\n        #expect(viewModel.verifiedFingerprints.contains(verifiedFingerprint))\n    }\n\n    @Test\n    func verificationViews_renderCoreBranches() throws {\n        let (viewModel, transport, _) = makeSmokeViewModel()\n        let peerID = PeerID(str: \"2122232425262728\")\n        let fingerprint = String(repeating: \"cd\", count: 32)\n        var isPresented = true\n\n        transport.peerFingerprints[peerID] = fingerprint\n        transport.updatePeerSnapshots([makeSnapshot(peerID: peerID, nickname: \"Verifier\", noiseByte: 0x33)])\n        viewModel.selectedPrivateChatPeer = peerID\n        viewModel.verifiedFingerprints.insert(fingerprint)\n\n        let image = try makeCGImage()\n\n        let myQR = MyQRView(qrString: \"bitchat://verify?name=alice&npub=npub1test\")\n        let qrCode = QRCodeImage(data: \"bitchat://verify?hello=world\", size: 96)\n        let imageWrapper = ImageWrapper(image: image)\n\n        _ = myQR.body\n        _ = qrCode.body\n        _ = imageWrapper.body\n        _ = mount(myQR)\n        _ = mount(qrCode)\n        _ = mount(imageWrapper)\n        _ = mount(\n            VerificationSheetView(\n                isPresented: Binding(\n                    get: { isPresented },\n                    set: { isPresented = $0 }\n                )\n            )\n            .environmentObject(viewModel)\n        )\n    }\n\n    @Test\n    func meshPeerList_renders_emptyAndPopulatedStates() async {\n        let (viewModel, transport, identityManager) = makeSmokeViewModel()\n        let connectedPeer = PeerID(str: \"3132333435363738\")\n        let blockedPeer = PeerID(str: \"4142434445464748\")\n        let blockedFingerprint = String(repeating: \"ef\", count: 32)\n\n        _ = mount(\n            MeshPeerList(\n                viewModel: viewModel,\n                textColor: .green,\n                secondaryTextColor: .gray,\n                onTapPeer: { _ in },\n                onToggleFavorite: { _ in },\n                onShowFingerprint: { _ in }\n            )\n        )\n        _ = MeshPeerList(\n            viewModel: viewModel,\n            textColor: .green,\n            secondaryTextColor: .gray,\n            onTapPeer: { _ in },\n            onToggleFavorite: { _ in },\n            onShowFingerprint: { _ in }\n        ).body\n\n        transport.peerFingerprints[blockedPeer] = blockedFingerprint\n        identityManager.setBlocked(blockedFingerprint, isBlocked: true)\n        transport.updatePeerSnapshots([\n            makeSnapshot(peerID: connectedPeer, nickname: \"Alice\", noiseByte: 0x44),\n            makeSnapshot(peerID: blockedPeer, nickname: \"Mallory\", noiseByte: 0x55)\n        ])\n        try? await Task.sleep(nanoseconds: 50_000_000)\n        viewModel.unreadPrivateMessages.insert(blockedPeer)\n\n        _ = mount(\n            MeshPeerList(\n                viewModel: viewModel,\n                textColor: .green,\n                secondaryTextColor: .gray,\n                onTapPeer: { _ in },\n                onToggleFavorite: { _ in },\n                onShowFingerprint: { _ in }\n            )\n        )\n\n        #expect(viewModel.hasUnreadMessages(for: blockedPeer))\n    }\n\n    @Test\n    func commandSuggestionsAndLocationViews_render() {\n        let (viewModel, _, _) = makeSmokeViewModel()\n        let channel = GeohashChannel(level: .city, geohash: \"u4pruy\")\n        var messageText = \"/f\"\n\n        LocationChannelManager.shared.select(.location(channel))\n\n        _ = mount(\n            CommandSuggestionsView(\n                messageText: Binding(\n                    get: { messageText },\n                    set: { messageText = $0 }\n                ),\n                textColor: .green,\n                backgroundColor: .black,\n                secondaryTextColor: .gray\n            )\n            .environmentObject(viewModel)\n        )\n\n        _ = mount(\n            LocationChannelsSheet(isPresented: .constant(true))\n                .environmentObject(viewModel)\n        )\n\n        #expect(messageText == \"/f\")\n        LocationChannelManager.shared.select(.mesh)\n        LocationChannelManager.shared.endLiveRefresh()\n    }\n\n    @Test\n    func locationNotesView_rendersNoRelayAndLoadedStates() throws {\n        let (viewModel, _, _) = makeSmokeViewModel()\n\n        let noRelayManager = LocationNotesManager(\n            geohash: \"u4pruydq\",\n            dependencies: LocationNotesDependencies(\n                relayLookup: { _, _ in [] },\n                subscribe: { _, _, _, _, _ in },\n                unsubscribe: { _ in },\n                sendEvent: { _, _ in },\n                deriveIdentity: { _ in try NostrIdentity.generate() },\n                now: { Date() }\n            )\n        )\n\n        var noteHandler: ((NostrEvent) -> Void)?\n        var eose: (() -> Void)?\n        let loadedManager = LocationNotesManager(\n            geohash: \"u4pruydq\",\n            dependencies: LocationNotesDependencies(\n                relayLookup: { _, _ in [\"wss://relay.one\"] },\n                subscribe: { _, _, _, handler, onEOSE in\n                    noteHandler = handler\n                    eose = onEOSE\n                },\n                unsubscribe: { _ in },\n                sendEvent: { _, _ in },\n                deriveIdentity: { _ in try NostrIdentity.generate() },\n                now: { Date() }\n            )\n        )\n\n        let identity = try NostrIdentity.generate()\n        let event = try NostrEvent(\n            pubkey: identity.publicKeyHex,\n            createdAt: Date(),\n            kind: .textNote,\n            tags: [[\"g\", \"u4pruydq\"], [\"n\", \"Builder\"]],\n            content: \"hello from a note\"\n        ).sign(with: identity.schnorrSigningKey())\n        noteHandler?(event)\n        eose?()\n\n        _ = mount(\n            LocationNotesView(geohash: \"u4pruydq\", manager: noRelayManager)\n                .environmentObject(viewModel)\n        )\n        _ = mount(\n            LocationNotesView(geohash: \"u4pruydq\", manager: loadedManager)\n                .environmentObject(viewModel)\n        )\n\n        #expect(loadedManager.notes.count == 1)\n        #expect(noRelayManager.state == .noRelays)\n    }\n\n    @Test\n    func appInfoAndComponentViews_render() {\n        let feature = AppInfoFeatureInfo(\n            icon: \"lock.fill\",\n            title: \"app_info.privacy.title\",\n            description: \"app_info.features.encryption.description\"\n        )\n\n        let appInfo = AppInfoView()\n        let header = SectionHeader(\"app_info.features.title\")\n        let featureRow = FeatureRow(info: feature)\n        let paymentCashu = PaymentChipView(paymentType: .cashu(\"cashuA_test-token\"))\n        let paymentLightning = PaymentChipView(paymentType: .lightning(\"lightning:lnbc1test\"))\n\n        _ = appInfo.body\n        _ = header.body\n        _ = featureRow.body\n        _ = paymentCashu.body\n        _ = paymentLightning.body\n        _ = DeliveryStatusView(status: .sending).body\n        _ = DeliveryStatusView(status: .sent).body\n        _ = DeliveryStatusView(status: .delivered(to: \"Alice\", at: Date())).body\n        _ = DeliveryStatusView(status: .read(by: \"Alice\", at: Date())).body\n        _ = DeliveryStatusView(status: .failed(reason: \"offline\")).body\n        _ = DeliveryStatusView(status: .partiallyDelivered(reached: 2, total: 3)).body\n        _ = mount(appInfo)\n        _ = mount(header)\n        _ = mount(featureRow)\n        _ = mount(paymentCashu)\n        _ = mount(paymentLightning)\n\n        #expect(PaymentChipView.PaymentType.cashu(\"cashuA_test-token\").url?.scheme == \"cashu\")\n        #expect(PaymentChipView.PaymentType.cashu(\"https://example.com/cashu\").url?.absoluteString == \"https://example.com/cashu\")\n        #expect(PaymentChipView.PaymentType.lightning(\"lightning:lnbc1test\").url?.scheme == \"lightning\")\n    }\n\n    @Test\n    func geohashAndTextMessageViews_renderCoreBranches() {\n        let (viewModel, _, _) = makeSmokeViewModel()\n        let geohashPeopleList = GeohashPeopleList(\n            viewModel: viewModel,\n            textColor: .green,\n            secondaryTextColor: .gray,\n            onTapPerson: {}\n        )\n        var expandedMessageIDs: Set<String> = []\n        let longMessage = BitchatMessage(\n            sender: viewModel.nickname,\n            content: String(repeating: \"verylongtoken\", count: 12) + \" lightning:lnbc1test cashuA_test-token\",\n            timestamp: Date(),\n            isRelay: false,\n            isPrivate: true,\n            recipientNickname: \"Bob\",\n            deliveryStatus: .partiallyDelivered(reached: 1, total: 2)\n        )\n\n        _ = geohashPeopleList.body\n        _ = mount(geohashPeopleList)\n        _ = mount(\n            TextMessageView(\n                message: longMessage,\n                expandedMessageIDs: Binding(\n                    get: { expandedMessageIDs },\n                    set: { expandedMessageIDs = $0 }\n                )\n            )\n            .environmentObject(viewModel)\n        )\n\n        #expect(expandedMessageIDs.isEmpty)\n    }\n\n    @Test\n    func voiceAndMediaViews_renderAndWarmCaches() async throws {\n        let audioURL = try makeTemporaryAudioURL()\n        let imageURL = try makeTemporaryImageURL()\n        defer {\n            try? FileManager.default.removeItem(at: audioURL)\n            try? FileManager.default.removeItem(at: imageURL)\n            WaveformCache.shared.purge(url: audioURL)\n        }\n\n        let waveformView = WaveformView(\n            samples: [0.1, 0.6, 0.3, 0.8],\n            playbackProgress: 0.25,\n            sendProgress: 0.75,\n            onSeek: nil,\n            isInteractive: false\n        )\n        let imageView = BlockRevealImageView(\n            url: imageURL,\n            revealProgress: 0.5,\n            isSending: true,\n            onCancel: {},\n            initiallyBlurred: true,\n            onOpen: {},\n            onDelete: {}\n        )\n        let voiceNoteView = VoiceNoteView(\n            url: audioURL,\n            isSending: true,\n            sendProgress: 0.4,\n            onCancel: {}\n        )\n        let playback = VoiceNotePlaybackController(url: audioURL)\n\n        _ = waveformView.body\n        _ = imageView.body\n        _ = mount(waveformView)\n        _ = mount(imageView)\n        _ = mount(voiceNoteView)\n\n        let bins = await withCheckedContinuation { continuation in\n            WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in\n                continuation.resume(returning: values)\n            }\n        }\n        playback.loadDuration()\n        try? await Task.sleep(nanoseconds: 250_000_000)\n        playback.seek(to: 1.25)\n        playback.stop()\n        VoiceNotePlaybackCoordinator.shared.activate(playback)\n        VoiceNotePlaybackCoordinator.shared.deactivate(playback)\n        VoiceRecorder.shared.cancelRecording()\n\n        #expect(bins.count == 16)\n        #expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16)\n        #expect(playback.duration > 0)\n        #expect(playback.progress == 0)\n        #expect(VoiceRecorder.shared.currentAveragePower() <= 0)\n    }\n\n    #if os(iOS)\n    @Test\n    func cameraScannerView_previewAndCoordinatorSmoke() {\n        let preview = CameraScannerView.PreviewView(frame: .zero)\n        let coordinator = CameraScannerView.Coordinator()\n\n        _ = CameraScannerView.PreviewView.layerClass\n        _ = preview.videoPreviewLayer\n        coordinator.setup(sessionOwner: preview) { _ in }\n        coordinator.setActive(false)\n\n        #expect(preview.videoPreviewLayer.videoGravity == .resizeAspectFill)\n    }\n    #endif\n}\n"
  },
  {
    "path": "bitchatTests/XChaCha20Poly1305CompatTests.swift",
    "content": "//\n// XChaCha20Poly1305CompatTests.swift\n// bitchatTests\n//\n// Tests for XChaCha20-Poly1305 encryption with proper error handling.\n// This is free and unencumbered software released into the public domain.\n//\n\nimport Testing\nimport struct Foundation.Data\n@testable import bitchat\n\nstruct XChaCha20Poly1305CompatTests {\n\n    @Test func sealAndOpenRoundtrip() throws {\n        let plaintext = \"Hello, XChaCha20-Poly1305!\".data(using: .utf8)!\n        let key = Data(repeating: 0x42, count: 32)\n        let nonce = Data(repeating: 0x24, count: 24)\n\n        let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce)\n        let decrypted = try XChaCha20Poly1305Compat.open(\n            ciphertext: sealed.ciphertext,\n            tag: sealed.tag,\n            key: key,\n            nonce24: nonce\n        )\n\n        #expect(decrypted == plaintext)\n    }\n\n    @Test func sealAndOpenWithAAD() throws {\n        let plaintext = \"Secret message\".data(using: .utf8)!\n        let key = Data(repeating: 0xAB, count: 32)\n        let nonce = Data(repeating: 0xCD, count: 24)\n        let aad = \"additional authenticated data\".data(using: .utf8)!\n\n        let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad)\n        let decrypted = try XChaCha20Poly1305Compat.open(\n            ciphertext: sealed.ciphertext,\n            tag: sealed.tag,\n            key: key,\n            nonce24: nonce,\n            aad: aad\n        )\n\n        #expect(decrypted == plaintext)\n    }\n\n    @Test func sealProducesDifferentCiphertextWithDifferentNonces() throws {\n        let plaintext = \"Same plaintext\".data(using: .utf8)!\n        let key = Data(repeating: 0x42, count: 32)\n        let nonce1 = Data(repeating: 0x01, count: 24)\n        let nonce2 = Data(repeating: 0x02, count: 24)\n\n        let sealed1 = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce1)\n        let sealed2 = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce2)\n\n        #expect(sealed1.ciphertext != sealed2.ciphertext)\n    }\n\n    @Test func sealThrowsOnShortKey() {\n        let plaintext = \"Test\".data(using: .utf8)!\n        let shortKey = Data(repeating: 0x42, count: 16)\n        let nonce = Data(repeating: 0x24, count: 24)\n\n        var didThrow = false\n        do {\n            _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: shortKey, nonce24: nonce)\n        } catch {\n            didThrow = true\n        }\n        #expect(didThrow)\n    }\n\n    @Test func sealThrowsOnLongKey() {\n        let plaintext = \"Test\".data(using: .utf8)!\n        let longKey = Data(repeating: 0x42, count: 64)\n        let nonce = Data(repeating: 0x24, count: 24)\n\n        var didThrow = false\n        do {\n            _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: longKey, nonce24: nonce)\n        } catch {\n            didThrow = true\n        }\n        #expect(didThrow)\n    }\n\n    @Test func sealThrowsOnEmptyKey() {\n        let plaintext = \"Test\".data(using: .utf8)!\n        let emptyKey = Data()\n        let nonce = Data(repeating: 0x24, count: 24)\n\n        var didThrow = false\n        do {\n            _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: emptyKey, nonce24: nonce)\n        } catch {\n            didThrow = true\n        }\n        #expect(didThrow)\n    }\n\n    @Test func openThrowsOnInvalidKeyLength() {\n        let ciphertext = Data(repeating: 0x00, count: 16)\n        let tag = Data(repeating: 0x00, count: 16)\n        let shortKey = Data(repeating: 0x42, count: 31)\n        let nonce = Data(repeating: 0x24, count: 24)\n\n        var didThrow = false\n        do {\n            _ = try XChaCha20Poly1305Compat.open(ciphertext: ciphertext, tag: tag, key: shortKey, nonce24: nonce)\n        } catch {\n            didThrow = true\n        }\n        #expect(didThrow)\n    }\n\n    @Test func sealThrowsOnShortNonce() {\n        let plaintext = \"Test\".data(using: .utf8)!\n        let key = Data(repeating: 0x42, count: 32)\n        let shortNonce = Data(repeating: 0x24, count: 12)\n\n        var didThrow = false\n        do {\n            _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: shortNonce)\n        } catch {\n            didThrow = true\n        }\n        #expect(didThrow)\n    }\n\n    @Test func sealThrowsOnLongNonce() {\n        let plaintext = \"Test\".data(using: .utf8)!\n        let key = Data(repeating: 0x42, count: 32)\n        let longNonce = Data(repeating: 0x24, count: 32)\n\n        var didThrow = false\n        do {\n            _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: longNonce)\n        } catch {\n            didThrow = true\n        }\n        #expect(didThrow)\n    }\n\n    @Test func sealThrowsOnEmptyNonce() {\n        let plaintext = \"Test\".data(using: .utf8)!\n        let key = Data(repeating: 0x42, count: 32)\n        let emptyNonce = Data()\n\n        var didThrow = false\n        do {\n            _ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: emptyNonce)\n        } catch {\n            didThrow = true\n        }\n        #expect(didThrow)\n    }\n\n    @Test func openThrowsOnInvalidNonceLength() {\n        let ciphertext = Data(repeating: 0x00, count: 16)\n        let tag = Data(repeating: 0x00, count: 16)\n        let key = Data(repeating: 0x42, count: 32)\n        let shortNonce = Data(repeating: 0x24, count: 23)\n\n        var didThrow = false\n        do {\n            _ = try XChaCha20Poly1305Compat.open(ciphertext: ciphertext, tag: tag, key: key, nonce24: shortNonce)\n        } catch {\n            didThrow = true\n        }\n        #expect(didThrow)\n    }\n\n    @Test func openFailsWithWrongKey() throws {\n        let plaintext = \"Secret\".data(using: .utf8)!\n        let correctKey = Data(repeating: 0x42, count: 32)\n        let wrongKey = Data(repeating: 0x43, count: 32)\n        let nonce = Data(repeating: 0x24, count: 24)\n\n        let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: correctKey, nonce24: nonce)\n\n        var didThrow = false\n        do {\n            _ = try XChaCha20Poly1305Compat.open(\n                ciphertext: sealed.ciphertext,\n                tag: sealed.tag,\n                key: wrongKey,\n                nonce24: nonce\n            )\n        } catch {\n            didThrow = true\n        }\n        #expect(didThrow)\n    }\n\n    @Test func openFailsWithTamperedCiphertext() throws {\n        let plaintext = \"Secret\".data(using: .utf8)!\n        let key = Data(repeating: 0x42, count: 32)\n        let nonce = Data(repeating: 0x24, count: 24)\n\n        let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce)\n\n        // Create tampered ciphertext by changing first byte\n        var tamperedBytes = [UInt8](sealed.ciphertext)\n        tamperedBytes[0] = tamperedBytes[0] ^ 0xFF\n        let tampered = Data(tamperedBytes)\n\n        var didThrow = false\n        do {\n            _ = try XChaCha20Poly1305Compat.open(\n                ciphertext: tampered,\n                tag: sealed.tag,\n                key: key,\n                nonce24: nonce\n            )\n        } catch {\n            didThrow = true\n        }\n        #expect(didThrow)\n    }\n}\n"
  },
  {
    "path": "docs/GeohashPresenceSpec.md",
    "content": "# Geohash Presence Specification\n\n## Overview\n\nThe 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.\n\n## Nostr Protocol\n\n### Event Kind\nA new ephemeral event kind is defined for presence heartbeats:\n- **Kind:** `20001` (`GEOHASH_PRESENCE`)\n- **Type:** Ephemeral (not stored by relays long-term)\n\n### Event Structure\nThe 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\".\n\n```json\n{\n  \"kind\": 20001,\n  \"created_at\": <timestamp>,\n  \"tags\": [\n    [\"g\", \"<geohash>\"]\n  ],\n  \"content\": \"\",\n  \"pubkey\": \"<geohash_derived_pubkey>\",\n  \"id\": \"<event_id>\",\n  \"sig\": \"<signature>\"\n}\n```\n\n*   **`content`**: Must be empty string.\n*   **`tags`**: Must include `[\"g\", \"<geohash>\"]`. Should NOT include `[\"n\", \"<nickname>\"]`.\n*   **`pubkey`**: The ephemeral identity derived specifically for this geohash (same as used for chat messages).\n\n## Client Behavior\n\n### 1. Broadcasting Presence\n\nClients MUST broadcast a Kind 20001 presence event globally when the app is open, regardless of which screen the user is viewing.\n\n*   **Global Heartbeat:**\n    *   **Trigger:** Application start / initialization, or whenever location (available geohashes) changes.\n    *   **Frequency:** Randomized loop interval between **40s and 80s** (average 60s).\n    *   **Scope:** Sent to *all* geohash channels corresponding to the device's *current physical location*.\n    *   **Privacy Restriction:** Presence MUST ONLY be broadcast to low-precision geohash levels to protect user privacy. Specifically:\n        *   **Allowed:** `REGION` (precision 2), `PROVINCE` (precision 4), `CITY` (precision 5).\n        *   **Denied:** `NEIGHBORHOOD` (precision 6), `BLOCK` (precision 7), `BUILDING` (precision 8+).\n    *   **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.\n\n### 2. Subscribing to Presence\n\nClients must update their Nostr filters to listen for both chat and presence events on geohash channels.\n\n*   **Filter:**\n    *   `kinds`: `[20000, 20001]`\n    *   `#g`: `[\"<geohash>\"]`\n\n### 3. Participant Counting\n\nThe \"online participants\" count shown in the UI aggregates unique public keys from both presence heartbeats and active chat messages.\n\n*   **Logic:**\n    *   Maintain a map of `pubkey -> last_seen_timestamp` for each geohash.\n    *   Update `last_seen_timestamp` upon receiving a valid **Kind 20001 (Presence)** OR **Kind 20000 (Chat)** event.\n    *   A participant is considered \"online\" if their `last_seen_timestamp` is within the last **5 minutes**.\n\n### 4. UI Presentation\n\nThe presentation of the participant count depends on the geohash precision level and data availability.\n\n*   **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]`.\n*   **High-Precision Uncertainty:** For high-precision channels (Neighborhood, Block, Building) where:\n    *   Presence broadcasting is disabled (privacy restriction).\n    *   **AND** the detected participant count is `0`.\n    *   **Display:** `[? people]`\n    *   **Reasoning:** Since clients don't announce themselves in these channels, a count of \"0\" is misleading (people could be lurking).\n\n### 5. Implementation Details (Android Reference)\n\n*   **`NostrKind.GEOHASH_PRESENCE`**: Added constant `20001`.\n*   **`NostrProtocol.createGeohashPresenceEvent`**: Helper to generate the event.\n*   **`GeohashViewModel`**:\n    *   `startGlobalPresenceHeartbeat()`: Coroutine that `collectLatest` on `LocationChannelManager.availableChannels`.\n    *   Implements randomized loop logic (40-80s) and per-broadcast random delays (2-5s).\n    *   Filters channels by `precision <= 5` before broadcasting.\n*   **`GeohashMessageHandler`**:\n    *   Refactored `onEvent` to update participant counts for both Kind 20000 and 20001.\n*   **`LocationChannelsSheet`**:\n    *   Implements the `[? people]` display logic for high-precision, zero-count channels.\n\n## Benefits\n\n*   **Accuracy:** Counts reflect both active listeners (via heartbeats) and active speakers (via messages).\n*   **Privacy:** High-precision location presence is NOT broadcast. Temporal correlation between different levels is obfuscated via random delays.\n*   **Consistency:** \"Online\" status is maintained globally while the app is open.\n*   **Transparency:** The UI correctly reflects uncertainty (`?`) when privacy rules prevent accurate passive counting.\n"
  },
  {
    "path": "docs/REQUEST_SYNC_MANAGER.md",
    "content": "# Request Sync Manager & V2 Packet Updates\n\nThis 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.\n\n## Overview\n\nThe 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.\n\nThe 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:\n1.  **Enforce Timestamp Validation**: Normal packets now require timestamps to be within 2 minutes of the local clock.\n2.  **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.\n3.  **Prevent Unsolicited Sync Floods**: Unsolicited RSR packets are rejected.\n\n## Protocol Changes\n\n### Binary Protocol Updates\n*   **New Flag**: `IS_RSR` (0x10) added to the packet header flags.\n*   **BitchatPacket**: Updated to include `isRSR: Bool` field.\n*   **Encoding/Decoding**: Updated `BinaryProtocol` to handle the new flag.\n\n### Request Sync Payload\nThe `REQUEST_SYNC` packet payload (TLV encoded) has been updated to include:\n*   **Future Filters**:\n    *   `sinceTimestamp` (Type 0x05): To request packets since a certain time (UInt64 big-endian).\n    *   `fragmentIdFilter` (Type 0x06): To request specific fragments (UTF-8 string).\n\n## Architecture\n\n### RequestSyncManager\nA new component (`Sync/RequestSyncManager.swift`) responsible for:\n*   **Tracking**: Stores `peerID -> timestamp` mappings for pending sync requests.\n*   **Validation**: `isValidResponse(from: PeerID, isRSR: Bool)` checks if an incoming RSR packet matches a pending request within the 30-second window.\n*   **Cleanup**: Periodically removes expired requests.\n\n### GossipSyncManager Updates\n*   **Unicast Sync**: Instead of blind broadcasting, the periodic sync task now iterates over connected peers and sends unicast `REQUEST_SYNC` packets.\n*   **Registration**: Before sending, requests are registered with `RequestSyncManager`.\n*   **Response Marking**: When responding to a `REQUEST_SYNC`, generated packets (Announce/Message) are explicitly marked with `isRSR = true` (and `ttl = 0`).\n\n### BLEService (Security Manager) Updates\n*   **Timestamp Enforcement**: Checks `abs(now - packetTimestamp) < 2 minutes` for standard packets.\n*   **Conditional Exemption**: If `packet.isRSR` is true (or packet is a legacy TTL=0 response), it queries `RequestSyncManager`.\n    *   **Valid**: If solicited, timestamp check is skipped (allowing historical data sync).\n    *   **Invalid**: If unsolicited or timed out, the packet is rejected.\n\n## Usage\n\nThese 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.\n"
  },
  {
    "path": "docs/SOURCE_ROUTING.md",
    "content": "# Source-Based Routing for BitChat Packets (v2)\n\nThis 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.\n\n**Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data).\n\n---\n\n## 1. Protocol Versioning & Layering\n\nTo support source routing and larger payloads, the packet format has been upgraded to **Version 2**.\n\n*   **Version 1 (Legacy):** 2-byte payload length limit. Ignores routing flags.\n*   **Version 2 (Current):** 4-byte payload length limit. Supports Source Routing.\n\n**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.\n\n---\n\n## 2. Packet Structure Comparison\n\nThe following diagram illustrates the structural differences between a standard v1 packet and a source-routed v2 packet.\n\n### V1 Packet (Legacy)\n```text\n+-------------------+---------------------------------------------------------+\n| Fixed Header (14) | Variable Sections                                       |\n+-------------------+----------+-------------+------------------+-------------+\n| Ver: 1 (1B)       | SenderID | RecipientID | Payload          | Signature   |\n| Type, TTL, etc.   | (8B)     | (8B)        | (Length in Head) | (64B)       |\n| Len: 2 Bytes      |          | (Optional)  |                  | (Optional)  |\n+-------------------+----------+-------------+------------------+-------------+\n```\n\n### V2 Packet (Source Routed)\n```text\n+-------------------+-----------------------------------------------------------------------------+\n| Fixed Header (16) | Variable Sections                                                           |\n+-------------------+----------+-------------+-----------------------+------------------+-------------+\n| Ver: 2 (1B)       | SenderID | RecipientID | SOURCE ROUTE          | Payload          | Signature   |\n| Type, TTL, etc.   | (8B)     | (8B)        | (Variable)            | (Length in Head) | (64B)       |\n| Len: 4 Bytes      |          | (Required*) | Only if HAS_ROUTE=1   |                  | (Optional)  |\n+-------------------+----------+-------------+-----------------------+------------------+-------------+\n```\n\n**(*) Note:** A `Route` can be attached to **any** packet type that has a `RecipientID` (flag `HAS_RECIPIENT` set).\n\n### Fixed Header Differences\n\n| Field | Size (v1) | Size (v2) | Description |\n|---|---|---|---|\n| **Version** | 1 byte | 1 byte | `0x01` vs `0x02` |\n| **Payload Length** | **2 bytes** | **4 bytes** | `UInt32` in v2 to support large files. **Excludes** route/IDs/sig. |\n| **Total Size** | **14 bytes** | **16 bytes** | V2 header is 2 bytes larger. |\n\n---\n\n## 3. Source Route Specification\n\nThe `Source Route` field is a variable-length list of **intermediate hops** that the packet must traverse.\n\n*   **Location:** Immediately follows `RecipientID`.\n*   **Structure:**\n    *   `Count` (1 byte): Number of intermediate hops (`N`).\n    *   `Hops` (`N * 8` bytes): Sequence of Peer IDs.\n\n### Intermediate Hops Only\nThe route list MUST contain **only** the intermediate relays between the sender and the recipient.\n*   **DO NOT** include the `SenderID` (it is already in the packet).\n*   **DO NOT** include the `RecipientID` (it is already in the packet).\n\n**Example:**\nTopology: `Alice (Sender) -> Bob -> Charlie -> Dave (Recipient)`\n*   Packet `SenderID`: Alice\n*   Packet `RecipientID`: Dave\n*   Packet `Route`: `[Bob, Charlie]` (Count = 2)\n\n---\n\n## 4. Topology Discovery (Gossip)\n\nTo calculate routes, nodes need a view of the network topology. This is achieved via a **Neighbor List** extension to the `IdentityAnnouncement` packet.\n\nThe `ANNOUNCE` packet payload now consists of a sequence of TLVs. The standard identity information is followed by an optional Gossip TLV.\n\n*   **Mechanism:** Appended to the `IdentityAnnouncement` payload.\n*   **New TLV Type:** `0x04` (Direct Neighbors).\n*   **Content:** A list of Peer IDs that the announcing node is directly connected to.\n\n**TLV Structure (Type 0x04):**\n```text\n[Type: 0x04] [Length: 1B] [NeighborID1 (8B)] [NeighborID2 (8B)] ...\n```\nThe `Length` field indicates the total size of the neighbor IDs in bytes (N * 8). There is no explicit count field.\n\nNodes receiving this TLV update their local mesh graph, linking the sender to the listed neighbors.\n\n### Edge Verification (Two-Way Handshake)\n\nTo prevent spoofing and routing through stale connections, the Mesh Graph service implements a strict two-way handshake verification:\n\n*   **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.\n*   **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.\n\n---\n\n## 5. Fragmentation & Source Routing\n\nWhen a large source-routed packet (e.g., File Transfer) exceeds the MTU and requires fragmentation:\n\n1.  **Version Inheritance:** All fragments MUST be marked as **Version 2**.\n2.  **Route Inheritance:** All fragments MUST contain the **exact same Route field** as the parent packet.\n\n**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.\n\n---\n\n## 6. Security & Signing\n\nSource routing is fully secured by the existing Ed25519 signature scheme.\n\n*   **Scope:** The signature covers the **entire packet structure** (Header + Sender + Recipient + Route + Payload).\n*   **Verification:** The receiver verifies the signature against the `SenderID`'s public key.\n*   **Integrity:** Any tampering with the route list by malicious relays will invalidate the signature, causing the packet to be dropped by the destination.\n\n**Signature Input Construction:**\nSerialize the packet exactly as transmitted, but temporarily set `TTL = 0` and remove the `Signature` bytes.\n\n---\n\n## 7. Relay Logic\n\nWhen a node receives a packet **not** addressed to itself:\n\n1.  **Check Route:**\n    *   Is `Version >= 2`?\n    *   Is `HAS_ROUTE` flag set?\n    *   Is the route list non-empty?\n2.  **If YES (Source Routed):**\n    *   Find local Peer ID in the route list at index `i`.\n    *   **Next Hop:** The peer at `i + 1`.\n    *   **Last Hop:** If `i` is the last index, the Next Hop is the `RecipientID`.\n    *   **Action:** Attempt to unicast (`sendToPeer`) to the Next Hop.\n    *   **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery.\n3.  **If NO (Standard):**\n    *   Flood the packet to all connected neighbors (subject to TTL and probability rules).\n"
  },
  {
    "path": "docs/TOR-INTEGRATION.md",
    "content": "Tor-by-default integration (scaffold)\n\nOverview\n- 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.\n- This repo now includes a minimal TorManager and TorURLSession to make dropping in an embedded Tor framework straightforward.\n\nKey pieces\n- TorManager\n  - Boots Tor, manages a DataDirectory under Application Support, exposes SOCKS at 127.0.0.1:39050, and provides awaitReady().\n  - Fails closed by default until Tor is bootstrapped. For local development only, define BITCHAT_DEV_ALLOW_CLEARNET to bypass Tor.\n- TorURLSession\n  - Provides a shared URLSession configured with a SOCKS5 proxy when Tor is enforced/ready.\n  - NostrRelayManager and GeoRelayDirectory now use this session and await Tor readiness before starting network activity.\n\nDrop‑in steps\n1) Build or obtain a small Tor framework\n   - Recommended: Tor C (client-only) with static linking and dead-strip.\n   - Configure Tor with a minimal feature set:\n     ./configure \\\n       --enable-static \\\n       --disable-asciidoc --disable-unittests --disable-manpage \\\n       --disable-zstd --disable-lzma --enable-zlib \\\n       --disable-systemd --disable-ptrace --disable-seccomp\n     CFLAGS=\"-Os -fdata-sections -ffunction-sections\" \\\n     LDFLAGS=\"-Wl,-dead_strip\"\n   - Build a tiny OpenSSL/LibreSSL (no engines, strip symbols) or reuse system crypto where permitted on macOS.\n\n2) Add the framework to Xcode targets\n   - 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).\n   - 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.\n   - On iOS, it will be embedded and signed automatically.\n\n3) Wire Tor bootstrap in TorManager.startTor()\n   - Two paths are already implemented:\n     - If a module named `Tor` is present (iCepa API), it starts `TORThread` directly.\n     - 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.\n   - `TorManager` writes a torrc and then probes `127.0.0.1:39050` until ready.\n\n4) Verify networking\n   - On app launch, TorManager.startIfNeeded() is called implicitly by awaitReady().\n   - NostrRelayManager.connect() awaits readiness, then creates WebSocket tasks via TorURLSession.shared.\n   - GeoRelayDirectory.fetchRemote() awaits readiness, then fetches via TorURLSession.shared.\n\n5) Optional macOS optimization\n   - 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.\n\ntorrc template\nThe generated torrc (under Application Support/bitchat/tor/torrc) is:\n\n  DataDirectory <AppSupport>/bitchat/tor\n  ClientOnly 1\n  SOCKSPort 127.0.0.1:39050\n  ControlPort 127.0.0.1:39051\n  CookieAuthentication 1\n  AvoidDiskWrites 1\n  MaxClientCircuitsPending 8\n\nDev bypass (local only)\n- To temporarily allow direct network without Tor for local development:\n  - Add Swift compiler flag: BITCHAT_DEV_ALLOW_CLEARNET\n  - This enables a clearnet session in TorURLSession when Tor isn’t present.\n  - Never enable this in release builds.\n\nNotes\n- We intentionally do not change any app-level APIs: consumers simply use TorURLSession via existing code paths.\n- When Tor is missing in release builds, the app will not connect (fail-closed), logging a clear reason.\n"
  },
  {
    "path": "docs/privacy-assessment.md",
    "content": "BitChat Privacy Assessment\n==========================\n\nScope\n- Mesh transport (BLE) behavior and metadata minimization\n- Nostr-based private message fallback (gift-wrapped, end-to-end encrypted)\n- Read receipts and delivery acknowledgments\n- Logging/telemetry posture and controls\n\nSummary\n- No accounts, no servers for mesh; Nostr used only for mutual favorites, with end-to-end Noise encryption encapsulated in gift wraps.\n- BLE announces contain only nickname and Noise pubkey. No device name, no plaintext identity beyond what the user broadcasts.\n- Discovery and flooding incorporate jitter and TTL caps to reduce linkability and propagation radius of encrypted payloads.\n- 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.\n- Logging defaults to conservative levels; debug verbosity is suppressed for release builds. A single env var can raise/lower threshold when needed.\n\nBLE Privacy Considerations\n- Announce content: Unchanged — nickname + Noise public key only.\n- Local Name: Not used (explicitly disabled). Avoids leaking device/OS identity.\n- Address: iOS uses BLE MAC randomization; BitChat does not attempt to set static addresses.\n- Announce jitter: Each announce is delayed by a small random jitter to avoid synchronization-based correlation.\n- Scanning: Foreground scanning uses “allow duplicates” briefly to improve discovery latency; background uses standard scanning parameters.\n- 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.\n- Fragmentation: Fragments use write-with-response for reliability (less re-broadcast churn = fewer repeated signals).\n- GATT permissions: Private characteristic disallows .read; we use notify/write/writeWithoutResponse to avoid exposing plaintext attributes over GATT.\n\nMesh Routing and Multi-hop Limits\n- Encrypted relays permitted with random per-hop delay (small jitter) to smooth floods.\n- TTL cap: Encrypted payloads are capped at 2 hops, limiting metadata spread and path reconstruction risk while enabling close-range relays.\n\nNostr Private Messaging Fallback\n- Usage criteria: Only attempted for mutual favorites or where a Nostr key has been exchanged (stored in favorites).\n- Payload confidentiality: Messages embed a BitChat Noise-encrypted packet inside a NIP-17 gift wrap; relays see only random-looking ciphertext.\n- Timestamp handling: Gift wraps add small randomized offsets to reduce exact timing correlation.\n- Read/delivery acks: Also encapsulated in gift wraps, preserving content secrecy and minimizing metadata.\n- Relay policy variance: Some relays apply “web-of-trust” policies and may reject events; BitChat tolerates partial delivery and still prefers mesh when available.\n\nRead Receipts and Delivery Acks\n- Routing policy: Prefer mesh if Noise session established; otherwise use Nostr when mapping exists.\n- Throttling: Nostr READ acks are queued and rate-limited (~3/s) to prevent relay rate limits during backlogs.\n- 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.\n\nData Retention and State\n- Messages: Ephemeral in-memory only; history is bounded per chat and trimmed.\n- Read-receipt IDs: Stored in `UserDefaults` for UX continuity; periodically pruned to IDs present in memory.\n- Favorites: Noise and optional Nostr keys with petnames; can be wiped via panic action.\n- Panic: Triple-tap clears keys, sessions, cached state, and disconnects transports.\n\nLogging and Telemetry\n- Centralized `SecureLogger` filters potential secrets and uses OSLog privacy markers.\n- Default level: `info`; release builds suppress debug. Developers can set `BITCHAT_LOG_LEVEL=debug|info|warning|error|fault`.\n- Transport routing, ACK sends, subscribe/connect noise were downgraded from info→debug.\n- OS/system errors (e.g., transient WebSocket disconnects) may still appear in system logs; BitChat avoids re-logging those unless actionable.\n\nResidual Risks and Mitigations\n- RF fingerprinting: BLE presence is observable at the RF layer; mitigated by minimal announce content and platform MAC randomization.\n- Timing correlation: Announce/relay jitter reduces but does not eliminate timing analysis. Avoids synchronized bursts.\n- Relay metadata: Nostr relays can see that an account posts gift wraps; content remains end-to-end encrypted. Favor mesh path when in range.\n\nRecommendations (Next)\n- Add optional coalesced READ behavior for large backlogs.\n- Expose a “low-visibility mode” to reduce scanning aggressiveness in sensitive contexts.\n- Allow user-configurable Nostr relay set with a “private relays only” toggle.\n\n"
  },
  {
    "path": "localPackages/Arti/.gitignore",
    "content": "/target/\n/.build/\n/.swiftpm/\n"
  },
  {
    "path": "localPackages/Arti/Cargo.toml",
    "content": "[workspace]\nresolver = \"2\"\nmembers = [\"arti-bitchat\"]\n\n[profile.release]\nopt-level = \"z\"\nlto = \"fat\"\ncodegen-units = 1\npanic = \"abort\"\nstrip = \"symbols\"\n"
  },
  {
    "path": "localPackages/Arti/Frameworks/arti.xcframework/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>AvailableLibraries</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>BinaryPath</key>\n\t\t\t<string>libarti_bitchat.a</string>\n\t\t\t<key>HeadersPath</key>\n\t\t\t<string>Headers</string>\n\t\t\t<key>LibraryIdentifier</key>\n\t\t\t<string>macos-arm64</string>\n\t\t\t<key>LibraryPath</key>\n\t\t\t<string>libarti_bitchat.a</string>\n\t\t\t<key>SupportedArchitectures</key>\n\t\t\t<array>\n\t\t\t\t<string>arm64</string>\n\t\t\t</array>\n\t\t\t<key>SupportedPlatform</key>\n\t\t\t<string>macos</string>\n\t\t</dict>\n\t\t<dict>\n\t\t\t<key>BinaryPath</key>\n\t\t\t<string>libarti_bitchat.a</string>\n\t\t\t<key>HeadersPath</key>\n\t\t\t<string>Headers</string>\n\t\t\t<key>LibraryIdentifier</key>\n\t\t\t<string>ios-arm64</string>\n\t\t\t<key>LibraryPath</key>\n\t\t\t<string>libarti_bitchat.a</string>\n\t\t\t<key>SupportedArchitectures</key>\n\t\t\t<array>\n\t\t\t\t<string>arm64</string>\n\t\t\t</array>\n\t\t\t<key>SupportedPlatform</key>\n\t\t\t<string>ios</string>\n\t\t</dict>\n\t\t<dict>\n\t\t\t<key>BinaryPath</key>\n\t\t\t<string>libarti_bitchat.a</string>\n\t\t\t<key>HeadersPath</key>\n\t\t\t<string>Headers</string>\n\t\t\t<key>LibraryIdentifier</key>\n\t\t\t<string>ios-arm64-simulator</string>\n\t\t\t<key>LibraryPath</key>\n\t\t\t<string>libarti_bitchat.a</string>\n\t\t\t<key>SupportedArchitectures</key>\n\t\t\t<array>\n\t\t\t\t<string>arm64</string>\n\t\t\t</array>\n\t\t\t<key>SupportedPlatform</key>\n\t\t\t<string>ios</string>\n\t\t\t<key>SupportedPlatformVariant</key>\n\t\t\t<string>simulator</string>\n\t\t</dict>\n\t</array>\n\t<key>CFBundlePackageType</key>\n\t<string>XFWK</string>\n\t<key>XCFrameworkFormatVersion</key>\n\t<string>1.0</string>\n</dict>\n</plist>\n"
  },
  {
    "path": "localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/Headers/arti.h",
    "content": "#ifndef ARTI_H\n#define ARTI_H\n\n#include <stdint.h>\n#include <stdbool.h>\n\n/**\n * Start Arti with a SOCKS5 proxy.\n *\n * # Arguments\n * * `data_dir` - Path to data directory for Tor state (C string)\n * * `socks_port` - Port for SOCKS5 proxy (e.g., 39050)\n *\n * # Returns\n * * 0 on success\n * * -1 if already running\n * * -2 if data_dir is invalid\n * * -3 if runtime initialization failed\n * * -4 if bootstrap failed\n */\nint arti_start(const char *data_dir, uint16_t socks_port);\n\n/**\n * Stop Arti gracefully.\n *\n * # Returns\n * * 0 on success\n * * -1 if not running\n */\nint arti_stop(void);\n\n/**\n * Check if Arti is currently running.\n *\n * # Returns\n * * 1 if running\n * * 0 if not running\n */\nint arti_is_running(void);\n\n/**\n * Get the current bootstrap progress (0-100).\n */\nint arti_bootstrap_progress(void);\n\n/**\n * Get the current bootstrap summary string.\n *\n * # Arguments\n * * `buf` - Buffer to write the summary into\n * * `len` - Length of the buffer\n *\n * # Returns\n * * Number of bytes written (not including null terminator)\n * * -1 if buffer is null or too small\n */\nint arti_bootstrap_summary(char *buf, int len);\n\n/**\n * Signal Arti to go dormant (reduce resource usage).\n * This is a hint; Arti may not fully support dormant mode yet.\n *\n * # Returns\n * * 0 on success\n * * -1 if not running\n */\nint arti_go_dormant(void);\n\n/**\n * Signal Arti to wake from dormant mode.\n *\n * # Returns\n * * 0 on success\n * * -1 if not running\n */\nint arti_wake(void);\n\n#endif  /* ARTI_H */\n"
  },
  {
    "path": "localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/Headers/arti.h",
    "content": "#ifndef ARTI_H\n#define ARTI_H\n\n#include <stdint.h>\n#include <stdbool.h>\n\n/**\n * Start Arti with a SOCKS5 proxy.\n *\n * # Arguments\n * * `data_dir` - Path to data directory for Tor state (C string)\n * * `socks_port` - Port for SOCKS5 proxy (e.g., 39050)\n *\n * # Returns\n * * 0 on success\n * * -1 if already running\n * * -2 if data_dir is invalid\n * * -3 if runtime initialization failed\n * * -4 if bootstrap failed\n */\nint arti_start(const char *data_dir, uint16_t socks_port);\n\n/**\n * Stop Arti gracefully.\n *\n * # Returns\n * * 0 on success\n * * -1 if not running\n */\nint arti_stop(void);\n\n/**\n * Check if Arti is currently running.\n *\n * # Returns\n * * 1 if running\n * * 0 if not running\n */\nint arti_is_running(void);\n\n/**\n * Get the current bootstrap progress (0-100).\n */\nint arti_bootstrap_progress(void);\n\n/**\n * Get the current bootstrap summary string.\n *\n * # Arguments\n * * `buf` - Buffer to write the summary into\n * * `len` - Length of the buffer\n *\n * # Returns\n * * Number of bytes written (not including null terminator)\n * * -1 if buffer is null or too small\n */\nint arti_bootstrap_summary(char *buf, int len);\n\n/**\n * Signal Arti to go dormant (reduce resource usage).\n * This is a hint; Arti may not fully support dormant mode yet.\n *\n * # Returns\n * * 0 on success\n * * -1 if not running\n */\nint arti_go_dormant(void);\n\n/**\n * Signal Arti to wake from dormant mode.\n *\n * # Returns\n * * 0 on success\n * * -1 if not running\n */\nint arti_wake(void);\n\n#endif  /* ARTI_H */\n"
  },
  {
    "path": "localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/Headers/arti.h",
    "content": "#ifndef ARTI_H\n#define ARTI_H\n\n#include <stdint.h>\n#include <stdbool.h>\n\n/**\n * Start Arti with a SOCKS5 proxy.\n *\n * # Arguments\n * * `data_dir` - Path to data directory for Tor state (C string)\n * * `socks_port` - Port for SOCKS5 proxy (e.g., 39050)\n *\n * # Returns\n * * 0 on success\n * * -1 if already running\n * * -2 if data_dir is invalid\n * * -3 if runtime initialization failed\n * * -4 if bootstrap failed\n */\nint arti_start(const char *data_dir, uint16_t socks_port);\n\n/**\n * Stop Arti gracefully.\n *\n * # Returns\n * * 0 on success\n * * -1 if not running\n */\nint arti_stop(void);\n\n/**\n * Check if Arti is currently running.\n *\n * # Returns\n * * 1 if running\n * * 0 if not running\n */\nint arti_is_running(void);\n\n/**\n * Get the current bootstrap progress (0-100).\n */\nint arti_bootstrap_progress(void);\n\n/**\n * Get the current bootstrap summary string.\n *\n * # Arguments\n * * `buf` - Buffer to write the summary into\n * * `len` - Length of the buffer\n *\n * # Returns\n * * Number of bytes written (not including null terminator)\n * * -1 if buffer is null or too small\n */\nint arti_bootstrap_summary(char *buf, int len);\n\n/**\n * Signal Arti to go dormant (reduce resource usage).\n * This is a hint; Arti may not fully support dormant mode yet.\n *\n * # Returns\n * * 0 on success\n * * -1 if not running\n */\nint arti_go_dormant(void);\n\n/**\n * Signal Arti to wake from dormant mode.\n *\n * # Returns\n * * 0 on success\n * * -1 if not running\n */\nint arti_wake(void);\n\n#endif  /* ARTI_H */\n"
  },
  {
    "path": "localPackages/Arti/Frameworks/include/arti.h",
    "content": "#ifndef ARTI_H\n#define ARTI_H\n\n#include <stdint.h>\n#include <stdbool.h>\n\n/**\n * Start Arti with a SOCKS5 proxy.\n *\n * # Arguments\n * * `data_dir` - Path to data directory for Tor state (C string)\n * * `socks_port` - Port for SOCKS5 proxy (e.g., 39050)\n *\n * # Returns\n * * 0 on success\n * * -1 if already running\n * * -2 if data_dir is invalid\n * * -3 if runtime initialization failed\n * * -4 if bootstrap failed\n */\nint arti_start(const char *data_dir, uint16_t socks_port);\n\n/**\n * Stop Arti gracefully.\n *\n * # Returns\n * * 0 on success\n * * -1 if not running\n */\nint arti_stop(void);\n\n/**\n * Check if Arti is currently running.\n *\n * # Returns\n * * 1 if running\n * * 0 if not running\n */\nint arti_is_running(void);\n\n/**\n * Get the current bootstrap progress (0-100).\n */\nint arti_bootstrap_progress(void);\n\n/**\n * Get the current bootstrap summary string.\n *\n * # Arguments\n * * `buf` - Buffer to write the summary into\n * * `len` - Length of the buffer\n *\n * # Returns\n * * Number of bytes written (not including null terminator)\n * * -1 if buffer is null or too small\n */\nint arti_bootstrap_summary(char *buf, int len);\n\n/**\n * Signal Arti to go dormant (reduce resource usage).\n * This is a hint; Arti may not fully support dormant mode yet.\n *\n * # Returns\n * * 0 on success\n * * -1 if not running\n */\nint arti_go_dormant(void);\n\n/**\n * Signal Arti to wake from dormant mode.\n *\n * # Returns\n * * 0 on success\n * * -1 if not running\n */\nint arti_wake(void);\n\n#endif  /* ARTI_H */\n"
  },
  {
    "path": "localPackages/Arti/Package.swift",
    "content": "// swift-tools-version:5.9\nimport PackageDescription\n\nlet package = Package(\n    name: \"Tor\",  // Keep name \"Tor\" for drop-in compatibility\n    platforms: [\n        .iOS(.v16),\n        .macOS(.v13),\n    ],\n    products: [\n        .library(\n            name: \"Tor\",\n            targets: [\"Tor\"]\n        ),\n    ],\n    dependencies: [\n        .package(path: \"../BitLogger\"),\n    ],\n    targets: [\n        // Main Swift target\n        .target(\n            name: \"Tor\",\n            dependencies: [\n                \"arti\",\n                .product(name: \"BitLogger\", package: \"BitLogger\"),\n            ],\n            path: \"Sources\",\n            exclude: [\"C\"],\n            sources: [\n                \"TorManager.swift\",\n                \"TorURLSession.swift\",\n                \"TorNotifications.swift\",\n            ],\n            linkerSettings: [\n                .linkedLibrary(\"resolv\"),\n                .linkedLibrary(\"z\"),\n                .linkedLibrary(\"sqlite3\"),\n            ]\n        ),\n        // Binary framework containing the Rust static library\n        .binaryTarget(\n            name: \"arti\",\n            path: \"Frameworks/arti.xcframework\"\n        ),\n    ]\n)\n"
  },
  {
    "path": "localPackages/Arti/Sources/C/arti_shim.c",
    "content": "// Empty shim file to satisfy SPM target requirements.\n// The actual implementation is in the Rust static library (arti.xcframework).\n// This file exists only to make SPM happy with a C target.\n"
  },
  {
    "path": "localPackages/Arti/Sources/C/include/arti.h",
    "content": "#ifndef ARTI_H\n#define ARTI_H\n\n#include <stdint.h>\n\n#ifdef __cplusplus\nextern \"C\" {\n#endif\n\n/**\n * Start Arti with a SOCKS5 proxy.\n *\n * @param data_dir Path to data directory for Tor state (C string)\n * @param socks_port Port for SOCKS5 proxy (e.g., 39050)\n * @return 0 on success, negative on error:\n *         -1: already running\n *         -2: invalid data_dir\n *         -3: runtime initialization failed\n *         -4: bootstrap failed\n */\nint32_t arti_start(const char *data_dir, uint16_t socks_port);\n\n/**\n * Stop Arti gracefully.\n *\n * @return 0 on success, -1 if not running\n */\nint32_t arti_stop(void);\n\n/**\n * Check if Arti is currently running.\n *\n * @return 1 if running, 0 if not running\n */\nint32_t arti_is_running(void);\n\n/**\n * Get the current bootstrap progress (0-100).\n *\n * @return Progress percentage\n */\nint32_t arti_bootstrap_progress(void);\n\n/**\n * Get the current bootstrap summary string.\n *\n * @param buf Buffer to write the summary into\n * @param len Length of the buffer\n * @return Number of bytes written, -1 on error\n */\nint32_t arti_bootstrap_summary(char *buf, int32_t len);\n\n/**\n * Signal Arti to go dormant (reduce resource usage).\n *\n * @return 0 on success, -1 if not running\n */\nint32_t arti_go_dormant(void);\n\n/**\n * Signal Arti to wake from dormant mode.\n *\n * @return 0 on success, -1 if not running\n */\nint32_t arti_wake(void);\n\n#ifdef __cplusplus\n}\n#endif\n\n#endif /* ARTI_H */\n"
  },
  {
    "path": "localPackages/Arti/Sources/C/include/module.modulemap",
    "content": "module ArtiC {\n    header \"arti.h\"\n    export *\n}\n"
  },
  {
    "path": "localPackages/Arti/Sources/TorManager.swift",
    "content": "import BitLogger\nimport Foundation\n#if canImport(Network)\nimport Network\n#endif\n\n#if !canImport(Network)\nprivate final class NWPathMonitor {\n    var pathUpdateHandler: ((Any) -> Void)?\n\n    func start(queue: DispatchQueue) {\n        // Path monitoring is unavailable on this platform; nothing to do.\n    }\n}\n#endif\n\n// FFI declarations for Arti (Rust)\n@_silgen_name(\"arti_start\")\nprivate func arti_start(_ dataDir: UnsafePointer<CChar>, _ socksPort: UInt16) -> Int32\n\n@_silgen_name(\"arti_stop\")\nprivate func arti_stop() -> Int32\n\n@_silgen_name(\"arti_is_running\")\nprivate func arti_is_running() -> Int32\n\n@_silgen_name(\"arti_bootstrap_progress\")\nprivate func arti_bootstrap_progress() -> Int32\n\n@_silgen_name(\"arti_bootstrap_summary\")\nprivate func arti_bootstrap_summary(_ buf: UnsafeMutablePointer<CChar>, _ len: Int32) -> Int32\n\n@_silgen_name(\"arti_go_dormant\")\nprivate func arti_go_dormant() -> Int32\n\n@_silgen_name(\"arti_wake\")\nprivate func arti_wake() -> Int32\n\n/// Arti-based Tor integration for BitChat.\n/// - Boots a local Arti client and exposes a SOCKS5 proxy\n///   on 127.0.0.1:socksPort. All app networking should await readiness and\n///   route via this proxy. Fails closed by default when Tor is unavailable.\n@MainActor\npublic final class TorManager: ObservableObject {\n    public static let shared = TorManager()\n\n    // SOCKS endpoint where Arti listens\n    let socksHost: String = \"127.0.0.1\"\n    let socksPort: Int = 39050\n\n    // State\n    @Published private(set) public var isReady: Bool = false\n    @Published private(set) var isStarting: Bool = false\n    @Published private(set) var lastError: Error?\n    @Published private(set) var bootstrapProgress: Int = 0\n    @Published private(set) var bootstrapSummary: String = \"\"\n\n    // Internal readiness trackers\n    private var socksReady: Bool = false { didSet { recomputeReady() } }\n    private var restarting: Bool = false\n\n    // Whether the app must enforce Tor for all connections (fail-closed).\n    public var torEnforced: Bool {\n        #if BITCHAT_DEV_ALLOW_CLEARNET\n        return false\n        #else\n        return true\n        #endif\n    }\n\n    // Returns true only when Tor is actually up (or dev fallback is compiled).\n    var networkPermitted: Bool {\n        if torEnforced { return isReady }\n        return true\n    }\n\n    private var didStart = false\n    private var bootstrapMonitorStarted = false\n    private var pathMonitor: NWPathMonitor?\n    private var isAppForeground: Bool = true\n    private var isDormant: Bool = false\n    private var lastRestartAt: Date? = nil\n    private var startedAt: Date? = nil  // Tracks initial startup time for grace period\n    private(set) var allowAutoStart: Bool = false\n\n    private init() {}\n\n    // MARK: - Public API\n\n    public func startIfNeeded() {\n        guard allowAutoStart else { return }\n        guard isAppForeground else { return }\n        guard !didStart else { return }\n        didStart = true\n        isDormant = false\n        isStarting = true\n        startedAt = Date()  // Track startup time for grace period\n        SecureLogger.debug(\"TorManager: startIfNeeded() - startedAt set\", category: .session)\n        lastError = nil\n        NotificationCenter.default.post(name: .TorWillStart, object: nil)\n        ensureFilesystemLayout()\n        startArti()\n        startPathMonitorIfNeeded()\n    }\n\n    public func setAppForeground(_ foreground: Bool) {\n        isAppForeground = foreground\n    }\n\n    public func isForeground() -> Bool { isAppForeground }\n\n    nonisolated\n    public func awaitReady(timeout: TimeInterval = 25.0) async -> Bool {\n        await MainActor.run {\n            if self.isAppForeground { self.startIfNeeded() }\n        }\n        let deadline = Date().addingTimeInterval(timeout)\n        if await MainActor.run(body: { self.networkPermitted }) { return true }\n        while Date() < deadline {\n            try? await Task.sleep(nanoseconds: 200_000_000)\n            if await MainActor.run(body: { self.networkPermitted }) { return true }\n        }\n        return await MainActor.run(body: { self.networkPermitted })\n    }\n\n    // MARK: - Filesystem\n\n    func dataDirectoryURL() -> URL? {\n        do {\n            let base = try FileManager.default.url(\n                for: .applicationSupportDirectory,\n                in: .userDomainMask,\n                appropriateFor: nil,\n                create: true\n            )\n            let dir = base.appendingPathComponent(\"bitchat/arti\", isDirectory: true)\n            return dir\n        } catch {\n            return nil\n        }\n    }\n\n    private func ensureFilesystemLayout() {\n        guard let dir = dataDirectoryURL() else { return }\n        do {\n            try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)\n        } catch {\n            // Non-fatal; Arti will surface errors during start if paths are missing\n        }\n    }\n\n    // MARK: - Arti Integration\n\n    private func startArti() {\n        guard let dir = dataDirectoryURL()?.path else {\n            isStarting = false\n            lastError = NSError(domain: \"TorManager\", code: -1, userInfo: [NSLocalizedDescriptionKey: \"No data directory\"])\n            return\n        }\n\n        // Check if already running\n        if arti_is_running() != 0 {\n            SecureLogger.info(\"TorManager: Arti already running\", category: .session)\n            startBootstrapMonitor()\n            return\n        }\n\n        let result = dir.withCString { dptr in\n            arti_start(dptr, UInt16(socksPort))\n        }\n\n        if result != 0 {\n            SecureLogger.error(\"TorManager: arti_start failed rc=\\(result)\", category: .session)\n            isStarting = false\n            lastError = NSError(domain: \"TorManager\", code: Int(result), userInfo: [NSLocalizedDescriptionKey: \"Arti start failed\"])\n            return\n        }\n\n        SecureLogger.info(\"TorManager: arti_start OK (SOCKS \\(socksHost):\\(socksPort))\", category: .session)\n        startBootstrapMonitor()\n\n        // Start SOCKS readiness probe\n        Task.detached(priority: .userInitiated) { [weak self] in\n            guard let self else { return }\n            let ready = await self.waitForSocksReady(timeout: 60.0)\n            await MainActor.run {\n                self.socksReady = ready\n                if ready {\n                    SecureLogger.info(\"TorManager: SOCKS ready at \\(self.socksHost):\\(self.socksPort)\", category: .session)\n                } else {\n                    self.lastError = NSError(domain: \"TorManager\", code: -14, userInfo: [NSLocalizedDescriptionKey: \"SOCKS not reachable\"])\n                    SecureLogger.error(\"TorManager: SOCKS not reachable (timeout)\", category: .session)\n                }\n            }\n        }\n    }\n\n    private func waitForSocksReady(timeout: TimeInterval) async -> Bool {\n        let deadline = Date().addingTimeInterval(timeout)\n        while Date() < deadline {\n            if await probeSocksOnce() { return true }\n            try? await Task.sleep(nanoseconds: 250_000_000)\n        }\n        return false\n    }\n\n    private func probeSocksOnce() async -> Bool {\n        #if canImport(Network)\n        await withCheckedContinuation { cont in\n            let params = NWParameters.tcp\n            let host = NWEndpoint.Host.ipv4(.loopback)\n            guard let port = NWEndpoint.Port(rawValue: UInt16(socksPort)) else {\n                cont.resume(returning: false)\n                return\n            }\n            let endpoint = NWEndpoint.hostPort(host: host, port: port)\n            let conn = NWConnection(to: endpoint, using: params)\n\n            var resumed = false\n            let resumeOnce: (Bool) -> Void = { value in\n                if !resumed {\n                    resumed = true\n                    cont.resume(returning: value)\n                }\n            }\n\n            conn.stateUpdateHandler = { state in\n                switch state {\n                case .ready:\n                    resumeOnce(true)\n                    conn.cancel()\n                case .failed, .cancelled:\n                    resumeOnce(false)\n                    conn.cancel()\n                default:\n                    break\n                }\n            }\n\n            DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 1.0) {\n                resumeOnce(false)\n                conn.cancel()\n            }\n\n            conn.start(queue: DispatchQueue.global(qos: .utility))\n        }\n        #else\n        return false\n        #endif\n    }\n\n    // MARK: - Bootstrap Monitoring\n\n    private func startBootstrapMonitor() {\n        guard !bootstrapMonitorStarted else { return }\n        bootstrapMonitorStarted = true\n        Task.detached(priority: .utility) { [weak self] in\n            await self?.bootstrapPollLoop()\n        }\n    }\n\n    private func bootstrapPollLoop() async {\n        let deadline = Date().addingTimeInterval(75)\n        while Date() < deadline {\n            let progress = Int(arti_bootstrap_progress())\n            let summary = getBootstrapSummary()\n\n            await MainActor.run {\n                self.bootstrapProgress = progress\n                self.bootstrapSummary = summary\n                if progress >= 100 { self.isStarting = false }\n                self.recomputeReady()\n            }\n\n            if progress >= 100 { break }\n            try? await Task.sleep(nanoseconds: 1_000_000_000)\n        }\n    }\n\n    private func getBootstrapSummary() -> String {\n        var buf = [CChar](repeating: 0, count: 256)\n        let len = arti_bootstrap_summary(&buf, Int32(buf.count))\n        if len > 0 {\n            return String(cString: buf)\n        }\n        return \"\"\n    }\n\n    // MARK: - Foreground/Background\n\n    public func ensureRunningOnForeground() {\n        if !allowAutoStart { return }\n        SecureLogger.debug(\"TorManager: ensureRunningOnForeground() started\", category: .session)\n        Task.detached(priority: .userInitiated) { [weak self] in\n            guard let self = self else { return }\n            let claimed: Bool = await MainActor.run {\n                if self.isStarting || self.restarting { return false }\n                self.restarting = true\n                return true\n            }\n            if !claimed { return }\n\n            // Check if already ready\n            let alreadyReady = await MainActor.run { self.isReady }\n            if alreadyReady {\n                await MainActor.run { self.restarting = false }\n                return\n            }\n\n            // Arti doesn't support dormant/wake (it's a no-op stub), so always do full restart\n            await self.restartArti()\n            await MainActor.run { self.restarting = false }\n        }\n    }\n\n    public func goDormantOnBackground() {\n        // Arti doesn't support real dormant mode, so just mark as not ready.\n        // iOS will suspend the runtime anyway. On foreground we do a full restart.\n        // Clear isStarting so foreground recovery can proceed if bootstrap was interrupted.\n        SecureLogger.debug(\"TorManager: goDormantOnBackground() called\", category: .session)\n        Task { @MainActor in\n            self.isReady = false\n            self.socksReady = false\n            self.isStarting = false\n        }\n    }\n\n    public func shutdownCompletely() {\n        SecureLogger.debug(\"TorManager: shutdownCompletely() called\", category: .session)\n        Task.detached { [weak self] in\n            guard let self = self else { return }\n            _ = arti_stop()\n\n            // Wait for shutdown\n            var waited = 0\n            while arti_is_running() != 0 && waited < 50 {\n                try? await Task.sleep(nanoseconds: 100_000_000)\n                waited += 1\n            }\n\n            await MainActor.run {\n                self.isDormant = false\n                self.isReady = false\n                self.socksReady = false\n                self.bootstrapProgress = 0\n                self.bootstrapSummary = \"\"\n                self.isStarting = false\n                self.didStart = false\n                self.restarting = false\n                self.bootstrapMonitorStarted = false\n                // Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()\n                // Clearing it here races with startup and defeats the grace period\n            }\n        }\n    }\n\n    private func restartArti() async {\n        SecureLogger.debug(\"TorManager: restartArti() starting\", category: .session)\n        await MainActor.run {\n            NotificationCenter.default.post(name: .TorWillRestart, object: nil)\n            self.isReady = false\n            self.socksReady = false\n            self.bootstrapProgress = 0\n            self.bootstrapSummary = \"\"\n            self.isStarting = true\n            self.isDormant = false\n            self.lastRestartAt = Date()\n        }\n\n        _ = arti_stop()\n\n        // Wait for stop\n        var waited = 0\n        while arti_is_running() != 0 && waited < 40 {\n            try? await Task.sleep(nanoseconds: 100_000_000)\n            waited += 1\n        }\n\n        await MainActor.run {\n            self.bootstrapMonitorStarted = false\n            self.didStart = false\n        }\n\n        await MainActor.run { self.startIfNeeded() }\n    }\n\n    private func recomputeReady() {\n        let ready = socksReady && bootstrapProgress >= 100\n        if ready != isReady {\n            if !ready {\n                SecureLogger.debug(\"TorManager: isReady -> false (socksReady=\\(socksReady), bootstrap=\\(bootstrapProgress))\", category: .session)\n            }\n            isReady = ready\n            if ready {\n                NotificationCenter.default.post(name: .TorDidBecomeReady, object: nil)\n            }\n        }\n    }\n\n    private func startPathMonitorIfNeeded() {\n        #if canImport(Network)\n        guard pathMonitor == nil else { return }\n        let monitor = NWPathMonitor()\n        pathMonitor = monitor\n        let queue = DispatchQueue(label: \"TorPathMonitor\")\n        monitor.pathUpdateHandler = { [weak self] _ in\n            Task { @MainActor in\n                guard let self = self else { return }\n                if self.isAppForeground {\n                    self.pokeTorOnPathChange()\n                }\n            }\n        }\n        monitor.start(queue: queue)\n        #endif\n    }\n\n    private func pokeTorOnPathChange() {\n        // Skip if we recently restarted\n        if let last = lastRestartAt, Date().timeIntervalSince(last) < 3.0 {\n            SecureLogger.debug(\"TorManager: pokeTorOnPathChange() skipped - recent restart\", category: .session)\n            return\n        }\n        // Skip during initial startup grace period (15s) to avoid race conditions\n        if let started = startedAt, Date().timeIntervalSince(started) < 15.0 {\n            SecureLogger.debug(\"TorManager: pokeTorOnPathChange() skipped - startup grace period (\\(Int(Date().timeIntervalSince(started)))s)\", category: .session)\n            return\n        }\n        if isStarting || restarting {\n            SecureLogger.debug(\"TorManager: pokeTorOnPathChange() skipped - isStarting=\\(isStarting) restarting=\\(restarting)\", category: .session)\n            return\n        }\n        if isReady { return }\n        SecureLogger.debug(\"TorManager: pokeTorOnPathChange() - Arti not ready, initiating recovery\", category: .session)\n        ensureRunningOnForeground()\n    }\n}\n\n// MARK: - Start policy configuration\nextension TorManager {\n    @MainActor\n    public func setAutoStartAllowed(_ allow: Bool) {\n        allowAutoStart = allow\n    }\n\n    @MainActor\n    public func isAutoStartAllowed() -> Bool { allowAutoStart }\n}\n"
  },
  {
    "path": "localPackages/Arti/Sources/TorNotifications.swift",
    "content": "import Foundation\n\npublic extension Notification.Name {\n    static let TorDidBecomeReady = Notification.Name(\"TorDidBecomeReady\")\n    static let TorWillRestart = Notification.Name(\"TorWillRestart\")\n    static let TorWillStart = Notification.Name(\"TorWillStart\")\n    static let TorUserPreferenceChanged = Notification.Name(\"TorUserPreferenceChanged\")\n}\n"
  },
  {
    "path": "localPackages/Arti/Sources/TorURLSession.swift",
    "content": "import Foundation\n#if os(macOS)\nimport CFNetwork\n#endif\n\n/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy\n/// when Tor is enforced/ready. Allows swapping between proxied and direct\n/// sessions so UI can toggle Tor usage at runtime.\npublic final class TorURLSession {\n    public static let shared = TorURLSession()\n\n    // Default (no proxy) session for direct Nostr access when Tor is disabled.\n    private var defaultSession: URLSession = TorURLSession.makeDefaultSession()\n\n    // Proxied (SOCKS5) session that routes through Tor.\n    private var torSession: URLSession = TorURLSession.makeTorSession()\n    private var useTorProxy: Bool = true\n\n    public var session: URLSession {\n        useTorProxy ? torSession : defaultSession\n    }\n\n    // Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.\n    public func rebuild() {\n        defaultSession = TorURLSession.makeDefaultSession()\n        torSession = TorURLSession.makeTorSession()\n    }\n\n    public func setProxyMode(useTor: Bool) {\n        guard useTorProxy != useTor else { return }\n        useTorProxy = useTor\n        rebuild()\n    }\n\n    private static func makeTorSession() -> URLSession {\n        let cfg = URLSessionConfiguration.ephemeral\n        cfg.waitsForConnectivity = true\n        // Keep in sync with TorManager defaults\n        let host = \"127.0.0.1\"\n        let port = 39050\n        #if os(macOS)\n        cfg.connectionProxyDictionary = [\n            kCFNetworkProxiesSOCKSEnable as String: 1,\n            kCFNetworkProxiesSOCKSProxy as String: host,\n            kCFNetworkProxiesSOCKSPort as String: port\n        ]\n        #else\n        // iOS: CFNetwork SOCKS proxy keys are unavailable at compile time.\n        cfg.connectionProxyDictionary = [\n            \"SOCKSEnable\": 1,\n            \"SOCKSProxy\": host,\n            \"SOCKSPort\": port\n        ]\n        #endif\n        return URLSession(configuration: cfg)\n    }\n\n    private static func makeDefaultSession() -> URLSession {\n        let cfg = URLSessionConfiguration.default\n        cfg.waitsForConnectivity = true\n        return URLSession(configuration: cfg)\n    }\n}\n"
  },
  {
    "path": "localPackages/Arti/arti-bitchat/Cargo.toml",
    "content": "[package]\nname = \"arti-bitchat\"\nversion = \"0.1.0\"\nedition = \"2021\"\nrust-version = \"1.86\"\n\n[lib]\ncrate-type = [\"staticlib\"]\n\n[dependencies]\n# Arti core - minimal features for client-only SOCKS proxy\narti-client = { version = \"0.38\", default-features = false, features = [\n    \"tokio\",\n    \"rustls\",\n] }\n\n# Async runtime\ntokio = { version = \"1\", default-features = false, features = [\n    \"rt-multi-thread\",\n    \"net\",\n    \"sync\",\n    \"time\",\n    \"macros\",\n] }\n\n# Tor runtime compatibility\ntor-rtcompat = { version = \"0.38\", default-features = false, features = [\"tokio\"] }\n\n# FFI utilities\nlibc = \"0.2\"\nonce_cell = \"1\"\n\n# Logging (minimal)\ntracing = \"0.1\"\ntracing-subscriber = { version = \"0.3\", default-features = false, features = [\"fmt\"] }\n\n[features]\ndefault = []\n"
  },
  {
    "path": "localPackages/Arti/arti-bitchat/cbindgen.toml",
    "content": "language = \"C\"\ninclude_guard = \"ARTI_H\"\nno_includes = true\nsys_includes = [\"stdint.h\", \"stdbool.h\"]\n\n[export]\ninclude = [\"arti_start\", \"arti_stop\", \"arti_is_running\", \"arti_bootstrap_progress\", \"arti_bootstrap_summary\", \"arti_go_dormant\", \"arti_wake\"]\n\n[fn]\nargs = \"Auto\"\n\n[parse]\nparse_deps = false\n"
  },
  {
    "path": "localPackages/Arti/arti-bitchat/src/lib.rs",
    "content": "//! arti-bitchat: Minimal FFI wrapper around arti-client for BitChat\n//!\n//! Provides a C-compatible interface for embedding Arti (Rust Tor) in iOS/macOS apps.\n//! Exposes a SOCKS5 proxy on localhost that Swift code can route traffic through.\n\nuse std::ffi::{c_char, c_int, CStr};\nuse std::net::SocketAddr;\nuse std::path::PathBuf;\nuse std::sync::atomic::{AtomicBool, AtomicI32, Ordering};\nuse std::sync::{Arc, Mutex};\n\nuse arti_client::TorClient;\nuse once_cell::sync::OnceCell;\nuse tokio::net::TcpListener;\nuse tokio::runtime::Runtime;\nuse tokio::sync::oneshot;\nuse tor_rtcompat::PreferredRuntime;\n\nmod socks;\n\n/// Global state for the Arti instance\nstruct ArtiState {\n    /// Tokio runtime (owned, single instance)\n    runtime: Runtime,\n    /// Shutdown signal sender\n    shutdown_tx: Option<oneshot::Sender<()>>,\n    /// TorClient handle for status queries\n    client: Option<Arc<TorClient<PreferredRuntime>>>,\n}\n\nstatic ARTI_STATE: OnceCell<Mutex<ArtiState>> = OnceCell::new();\nstatic BOOTSTRAP_PROGRESS: AtomicI32 = AtomicI32::new(0);\nstatic IS_RUNNING: AtomicBool = AtomicBool::new(false);\nstatic BOOTSTRAP_SUMMARY: Mutex<String> = Mutex::new(String::new());\n\n/// Initialize the global state with a new runtime\nfn init_state() -> Result<(), &'static str> {\n    ARTI_STATE.get_or_try_init(|| -> Result<Mutex<ArtiState>, &'static str> {\n        let runtime = Runtime::new().map_err(|_| \"Failed to create tokio runtime\")?;\n        Ok(Mutex::new(ArtiState {\n            runtime,\n            shutdown_tx: None,\n            client: None,\n        }))\n    })?;\n    Ok(())\n}\n\n/// Start Arti with a SOCKS5 proxy.\n///\n/// # Arguments\n/// * `data_dir` - Path to data directory for Tor state (C string)\n/// * `socks_port` - Port for SOCKS5 proxy (e.g., 39050)\n///\n/// # Returns\n/// * 0 on success\n/// * -1 if already running\n/// * -2 if data_dir is invalid\n/// * -3 if runtime initialization failed\n/// * -4 if bootstrap failed\n#[no_mangle]\npub extern \"C\" fn arti_start(data_dir: *const c_char, socks_port: u16) -> c_int {\n    // Check if already running\n    if IS_RUNNING.load(Ordering::SeqCst) {\n        return -1;\n    }\n\n    // Parse data directory\n    let data_path = match unsafe { CStr::from_ptr(data_dir) }.to_str() {\n        Ok(s) => PathBuf::from(s),\n        Err(_) => return -2,\n    };\n\n    // Initialize runtime if needed\n    if let Err(_) = init_state() {\n        return -3;\n    }\n\n    let state = match ARTI_STATE.get() {\n        Some(s) => s,\n        None => return -3,\n    };\n\n    let mut guard = match state.lock() {\n        Ok(g) => g,\n        Err(_) => return -3,\n    };\n\n    // Create shutdown channel\n    let (shutdown_tx, shutdown_rx) = oneshot::channel();\n    guard.shutdown_tx = Some(shutdown_tx);\n\n    let socks_addr: SocketAddr = format!(\"127.0.0.1:{}\", socks_port)\n        .parse()\n        .expect(\"valid addr\");\n\n    // Spawn the main Arti task\n    let data_path_clone = data_path.clone();\n    guard.runtime.spawn(async move {\n        match run_arti(data_path_clone, socks_addr, shutdown_rx).await {\n            Ok(_) => {\n                tracing::info!(\"Arti shutdown cleanly\");\n            }\n            Err(e) => {\n                tracing::error!(\"Arti error: {}\", e);\n                update_summary(&format!(\"Error: {}\", e));\n            }\n        }\n        IS_RUNNING.store(false, Ordering::SeqCst);\n        BOOTSTRAP_PROGRESS.store(0, Ordering::SeqCst);\n    });\n\n    IS_RUNNING.store(true, Ordering::SeqCst);\n    BOOTSTRAP_PROGRESS.store(0, Ordering::SeqCst);\n    update_summary(\"Starting...\");\n\n    0\n}\n\n/// Stop Arti gracefully.\n///\n/// # Returns\n/// * 0 on success\n/// * -1 if not running\n#[no_mangle]\npub extern \"C\" fn arti_stop() -> c_int {\n    if !IS_RUNNING.load(Ordering::SeqCst) {\n        return -1;\n    }\n\n    let state = match ARTI_STATE.get() {\n        Some(s) => s,\n        None => return -1,\n    };\n\n    let mut guard = match state.lock() {\n        Ok(g) => g,\n        Err(_) => return -1,\n    };\n\n    // Send shutdown signal\n    if let Some(tx) = guard.shutdown_tx.take() {\n        let _ = tx.send(());\n    }\n\n    // Clear client reference\n    guard.client = None;\n\n    // Give async tasks time to complete\n    std::thread::sleep(std::time::Duration::from_millis(200));\n\n    IS_RUNNING.store(false, Ordering::SeqCst);\n    BOOTSTRAP_PROGRESS.store(0, Ordering::SeqCst);\n    update_summary(\"\");\n\n    0\n}\n\n/// Check if Arti is currently running.\n///\n/// # Returns\n/// * 1 if running\n/// * 0 if not running\n#[no_mangle]\npub extern \"C\" fn arti_is_running() -> c_int {\n    if IS_RUNNING.load(Ordering::SeqCst) {\n        1\n    } else {\n        0\n    }\n}\n\n/// Get the current bootstrap progress (0-100).\n#[no_mangle]\npub extern \"C\" fn arti_bootstrap_progress() -> c_int {\n    BOOTSTRAP_PROGRESS.load(Ordering::SeqCst)\n}\n\n/// Get the current bootstrap summary string.\n///\n/// # Arguments\n/// * `buf` - Buffer to write the summary into\n/// * `len` - Length of the buffer\n///\n/// # Returns\n/// * Number of bytes written (not including null terminator)\n/// * -1 if buffer is null or too small\n#[no_mangle]\npub extern \"C\" fn arti_bootstrap_summary(buf: *mut c_char, len: c_int) -> c_int {\n    if buf.is_null() || len <= 0 {\n        return -1;\n    }\n\n    let summary = match BOOTSTRAP_SUMMARY.lock() {\n        Ok(s) => s.clone(),\n        Err(_) => return -1,\n    };\n\n    let bytes = summary.as_bytes();\n    let copy_len = std::cmp::min(bytes.len(), (len - 1) as usize);\n\n    unsafe {\n        std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, copy_len);\n        *buf.add(copy_len) = 0; // null terminator\n    }\n\n    copy_len as c_int\n}\n\n/// Signal Arti to go dormant (reduce resource usage).\n/// This is a hint; Arti may not fully support dormant mode yet.\n///\n/// # Returns\n/// * 0 on success\n/// * -1 if not running\n#[no_mangle]\npub extern \"C\" fn arti_go_dormant() -> c_int {\n    if !IS_RUNNING.load(Ordering::SeqCst) {\n        return -1;\n    }\n    // Arti doesn't have explicit dormant mode yet, but we can note the intent\n    update_summary(\"Dormant\");\n    0\n}\n\n/// Signal Arti to wake from dormant mode.\n///\n/// # Returns\n/// * 0 on success\n/// * -1 if not running\n#[no_mangle]\npub extern \"C\" fn arti_wake() -> c_int {\n    if !IS_RUNNING.load(Ordering::SeqCst) {\n        return -1;\n    }\n    update_summary(\"Active\");\n    0\n}\n\nfn update_summary(s: &str) {\n    if let Ok(mut guard) = BOOTSTRAP_SUMMARY.lock() {\n        guard.clear();\n        guard.push_str(s);\n    }\n}\n\n/// Main async entry point for Arti\nasync fn run_arti(\n    data_dir: PathBuf,\n    socks_addr: SocketAddr,\n    mut shutdown_rx: oneshot::Receiver<()>,\n) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {\n    // Ensure data directory exists\n    std::fs::create_dir_all(&data_dir)?;\n\n    update_summary(\"Configuring...\");\n\n    // Build Arti configuration with custom directories\n    let cache_dir = data_dir.join(\"cache\");\n    let state_dir = data_dir.join(\"state\");\n\n    // Use from_directories which sets up storage correctly\n    use arti_client::config::TorClientConfigBuilder;\n    let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir)\n        .build()?;\n\n    update_summary(\"Bootstrapping...\");\n\n    // Create and bootstrap the Tor client\n    let client = TorClient::create_bootstrapped(config).await?;\n    let client = Arc::new(client);\n\n    // Store client reference for status queries\n    if let Some(state) = ARTI_STATE.get() {\n        if let Ok(mut guard) = state.lock() {\n            guard.client = Some(client.clone());\n        }\n    }\n\n    // Mark bootstrap complete\n    BOOTSTRAP_PROGRESS.store(100, Ordering::SeqCst);\n    update_summary(\"Ready\");\n\n    // Bind SOCKS listener\n    let listener = TcpListener::bind(socks_addr).await?;\n    tracing::info!(\"SOCKS5 proxy listening on {}\", socks_addr);\n\n    // Accept connections until shutdown\n    loop {\n        tokio::select! {\n            accept_result = listener.accept() => {\n                match accept_result {\n                    Ok((stream, peer_addr)) => {\n                        let client = client.clone();\n                        tokio::spawn(async move {\n                            if let Err(e) = socks::handle_socks_connection(stream, peer_addr, client).await {\n                                tracing::debug!(\"SOCKS connection error from {}: {}\", peer_addr, e);\n                            }\n                        });\n                    }\n                    Err(e) => {\n                        tracing::warn!(\"Accept error: {}\", e);\n                    }\n                }\n            }\n            _ = &mut shutdown_rx => {\n                tracing::info!(\"Shutdown signal received\");\n                break;\n            }\n        }\n    }\n\n    update_summary(\"Shutting down...\");\n    Ok(())\n}\n"
  },
  {
    "path": "localPackages/Arti/arti-bitchat/src/socks.rs",
    "content": "//! SOCKS5 protocol handler for Arti\n//!\n//! Implements a minimal SOCKS5 server that forwards connections through Tor.\n\nuse std::io;\nuse std::net::SocketAddr;\nuse std::sync::Arc;\n\nuse arti_client::{TorClient, IntoTorAddr};\nuse tokio::io::{AsyncReadExt, AsyncWriteExt};\nuse tokio::net::TcpStream;\nuse tor_rtcompat::PreferredRuntime;\n\n// SOCKS5 constants\nconst SOCKS5_VERSION: u8 = 0x05;\nconst SOCKS5_AUTH_NONE: u8 = 0x00;\nconst SOCKS5_CMD_CONNECT: u8 = 0x01;\nconst SOCKS5_ATYP_IPV4: u8 = 0x01;\nconst SOCKS5_ATYP_DOMAIN: u8 = 0x03;\nconst SOCKS5_ATYP_IPV6: u8 = 0x04;\nconst SOCKS5_REP_SUCCESS: u8 = 0x00;\nconst SOCKS5_REP_FAILURE: u8 = 0x01;\nconst SOCKS5_REP_CONN_REFUSED: u8 = 0x05;\n\n/// Handle a single SOCKS5 connection\npub async fn handle_socks_connection(\n    mut stream: TcpStream,\n    peer_addr: SocketAddr,\n    client: Arc<TorClient<PreferredRuntime>>,\n) -> io::Result<()> {\n    // --- Greeting ---\n    // Client sends: VER | NMETHODS | METHODS\n    let mut greeting = [0u8; 2];\n    stream.read_exact(&mut greeting).await?;\n\n    if greeting[0] != SOCKS5_VERSION {\n        return Err(io::Error::new(\n            io::ErrorKind::InvalidData,\n            \"Not SOCKS5\",\n        ));\n    }\n\n    let nmethods = greeting[1] as usize;\n    let mut methods = vec![0u8; nmethods];\n    stream.read_exact(&mut methods).await?;\n\n    // We only support no-auth\n    if !methods.contains(&SOCKS5_AUTH_NONE) {\n        // Send failure: no acceptable methods\n        stream.write_all(&[SOCKS5_VERSION, 0xFF]).await?;\n        return Err(io::Error::new(\n            io::ErrorKind::PermissionDenied,\n            \"No acceptable auth methods\",\n        ));\n    }\n\n    // Accept no-auth\n    stream.write_all(&[SOCKS5_VERSION, SOCKS5_AUTH_NONE]).await?;\n\n    // --- Request ---\n    // Client sends: VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT\n    let mut request_header = [0u8; 4];\n    stream.read_exact(&mut request_header).await?;\n\n    if request_header[0] != SOCKS5_VERSION {\n        return Err(io::Error::new(\n            io::ErrorKind::InvalidData,\n            \"Invalid SOCKS5 request version\",\n        ));\n    }\n\n    let cmd = request_header[1];\n    let atyp = request_header[3];\n\n    if cmd != SOCKS5_CMD_CONNECT {\n        // We only support CONNECT\n        send_reply(&mut stream, SOCKS5_REP_FAILURE).await?;\n        return Err(io::Error::new(\n            io::ErrorKind::Unsupported,\n            \"Only CONNECT supported\",\n        ));\n    }\n\n    // Parse destination address\n    let (dest_host, dest_port) = match atyp {\n        SOCKS5_ATYP_IPV4 => {\n            let mut addr = [0u8; 4];\n            stream.read_exact(&mut addr).await?;\n            let mut port_buf = [0u8; 2];\n            stream.read_exact(&mut port_buf).await?;\n            let port = u16::from_be_bytes(port_buf);\n            let host = format!(\"{}.{}.{}.{}\", addr[0], addr[1], addr[2], addr[3]);\n            (host, port)\n        }\n        SOCKS5_ATYP_DOMAIN => {\n            let mut len_buf = [0u8; 1];\n            stream.read_exact(&mut len_buf).await?;\n            let len = len_buf[0] as usize;\n            let mut domain = vec![0u8; len];\n            stream.read_exact(&mut domain).await?;\n            let mut port_buf = [0u8; 2];\n            stream.read_exact(&mut port_buf).await?;\n            let port = u16::from_be_bytes(port_buf);\n            let host = String::from_utf8_lossy(&domain).to_string();\n            (host, port)\n        }\n        SOCKS5_ATYP_IPV6 => {\n            let mut addr = [0u8; 16];\n            stream.read_exact(&mut addr).await?;\n            let mut port_buf = [0u8; 2];\n            stream.read_exact(&mut port_buf).await?;\n            let port = u16::from_be_bytes(port_buf);\n            // Format IPv6 address\n            let segments: Vec<String> = addr\n                .chunks(2)\n                .map(|c| format!(\"{:02x}{:02x}\", c[0], c[1]))\n                .collect();\n            let host = format!(\"[{}]\", segments.join(\":\"));\n            (host, port)\n        }\n        _ => {\n            send_reply(&mut stream, SOCKS5_REP_FAILURE).await?;\n            return Err(io::Error::new(\n                io::ErrorKind::InvalidData,\n                \"Unsupported address type\",\n            ));\n        }\n    };\n\n    tracing::debug!(\"SOCKS5 CONNECT from {} to {}:{}\", peer_addr, dest_host, dest_port);\n\n    // Connect through Tor\n    let tor_addr = format!(\"{}:{}\", dest_host, dest_port);\n    let tor_addr = match tor_addr.as_str().into_tor_addr() {\n        Ok(a) => a,\n        Err(e) => {\n            tracing::debug!(\"Invalid Tor address: {}\", e);\n            send_reply(&mut stream, SOCKS5_REP_FAILURE).await?;\n            return Err(io::Error::new(\n                io::ErrorKind::InvalidInput,\n                format!(\"Invalid Tor address: {}\", e),\n            ));\n        }\n    };\n\n    let tor_stream = match client.connect(tor_addr).await {\n        Ok(s) => s,\n        Err(e) => {\n            tracing::debug!(\"Tor connect failed: {}\", e);\n            send_reply(&mut stream, SOCKS5_REP_CONN_REFUSED).await?;\n            return Err(io::Error::new(\n                io::ErrorKind::ConnectionRefused,\n                e.to_string(),\n            ));\n        }\n    };\n\n    // Send success reply\n    // Reply: VER | REP | RSV | ATYP | BND.ADDR | BND.PORT\n    // We use 0.0.0.0:0 as the bound address since we're proxying\n    let reply = [\n        SOCKS5_VERSION,\n        SOCKS5_REP_SUCCESS,\n        0x00, // RSV\n        SOCKS5_ATYP_IPV4,\n        0, 0, 0, 0, // BND.ADDR\n        0, 0, // BND.PORT\n    ];\n    stream.write_all(&reply).await?;\n\n    // Bidirectional copy\n    let (mut client_read, mut client_write) = stream.into_split();\n    let (mut tor_read, mut tor_write) = tor_stream.split();\n\n    let client_to_tor = async {\n        tokio::io::copy(&mut client_read, &mut tor_write).await\n    };\n    let tor_to_client = async {\n        tokio::io::copy(&mut tor_read, &mut client_write).await\n    };\n\n    tokio::select! {\n        result = client_to_tor => {\n            if let Err(e) = result {\n                tracing::debug!(\"Client to Tor copy error: {}\", e);\n            }\n        }\n        result = tor_to_client => {\n            if let Err(e) = result {\n                tracing::debug!(\"Tor to client copy error: {}\", e);\n            }\n        }\n    }\n\n    Ok(())\n}\n\nasync fn send_reply(stream: &mut TcpStream, rep: u8) -> io::Result<()> {\n    let reply = [\n        SOCKS5_VERSION,\n        rep,\n        0x00, // RSV\n        SOCKS5_ATYP_IPV4,\n        0, 0, 0, 0, // BND.ADDR\n        0, 0, // BND.PORT\n    ];\n    stream.write_all(&reply).await\n}\n"
  },
  {
    "path": "localPackages/Arti/build-ios.sh",
    "content": "#!/bin/bash\n#\n# Build arti-bitchat for iOS/macOS with aggressive size optimization\n#\n# Output: Frameworks/arti.xcframework containing static libraries for:\n#   - aarch64-apple-ios (iOS device)\n#   - aarch64-apple-ios-sim (iOS simulator, Apple Silicon)\n#   - x86_64-apple-ios (iOS simulator, Intel - optional)\n#   - aarch64-apple-darwin (macOS)\n#\nset -e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\ncd \"$SCRIPT_DIR\"\n\n# Configuration\nCRATE_NAME=\"arti-bitchat\"\nLIB_NAME=\"libarti_bitchat.a\"\nFRAMEWORK_NAME=\"arti\"\nOUTPUT_DIR=\"$SCRIPT_DIR/Frameworks\"\n\n# Targets to build\nTARGETS=(\n    \"aarch64-apple-ios\"           # iOS device\n    \"aarch64-apple-ios-sim\"       # iOS simulator (Apple Silicon)\n    \"aarch64-apple-darwin\"        # macOS\n)\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m' # No Color\n\nlog_info() { echo -e \"${GREEN}[INFO]${NC} $1\"; }\nlog_warn() { echo -e \"${YELLOW}[WARN]${NC} $1\"; }\nlog_error() { echo -e \"${RED}[ERROR]${NC} $1\"; }\n\n# Check prerequisites\ncheck_prerequisites() {\n    log_info \"Checking prerequisites...\"\n\n    if ! command -v rustc &> /dev/null; then\n        log_error \"Rust is not installed. Please install via rustup.\"\n        exit 1\n    fi\n\n    if ! command -v cargo &> /dev/null; then\n        log_error \"Cargo is not installed. Please install via rustup.\"\n        exit 1\n    fi\n\n    # Check/install targets\n    for target in \"${TARGETS[@]}\"; do\n        if ! rustup target list --installed | grep -q \"$target\"; then\n            log_info \"Installing target: $target\"\n            rustup target add \"$target\"\n        fi\n    done\n\n    # Install cbindgen if needed\n    if ! command -v cbindgen &> /dev/null; then\n        log_info \"Installing cbindgen...\"\n        cargo install cbindgen\n    fi\n\n    log_info \"Prerequisites OK\"\n}\n\n# Set up aggressive size optimization flags and deployment targets\nsetup_rustflags() {\n    local target=\"$1\"\n\n    # Base flags for size optimization\n    export RUSTFLAGS=\"-C opt-level=z -C lto=fat -C codegen-units=1 -C panic=abort -C strip=symbols\"\n\n    # Set deployment targets to suppress linker warnings about version mismatches\n    case \"$target\" in\n        *-apple-ios-sim*)\n            export IPHONEOS_DEPLOYMENT_TARGET=\"16.0\"\n            # Simulator uses iPhone SDK but needs the sim target\n            ;;\n        *-apple-ios*)\n            export IPHONEOS_DEPLOYMENT_TARGET=\"16.0\"\n            ;;\n        *-apple-darwin*)\n            export MACOSX_DEPLOYMENT_TARGET=\"13.0\"\n            ;;\n    esac\n\n    log_info \"RUSTFLAGS: $RUSTFLAGS\"\n    log_info \"Deployment target: MACOSX=$MACOSX_DEPLOYMENT_TARGET IPHONEOS=$IPHONEOS_DEPLOYMENT_TARGET\"\n}\n\n# Build for a single target\nbuild_target() {\n    local target=\"$1\"\n    log_info \"Building for target: $target\"\n\n    setup_rustflags \"$target\"\n\n    # Build release\n    cargo build --release --target \"$target\" -p \"$CRATE_NAME\"\n\n    # Check output\n    local lib_path=\"target/$target/release/$LIB_NAME\"\n    if [[ -f \"$lib_path\" ]]; then\n        local size=$(du -h \"$lib_path\" | cut -f1)\n        log_info \"Built $lib_path ($size)\"\n    else\n        log_error \"Build failed: $lib_path not found\"\n        exit 1\n    fi\n}\n\n# Create xcframework from built libraries\ncreate_xcframework() {\n    log_info \"Creating xcframework...\"\n\n    local xcframework_path=\"$OUTPUT_DIR/$FRAMEWORK_NAME.xcframework\"\n\n    # Remove existing xcframework\n    rm -rf \"$xcframework_path\"\n    mkdir -p \"$OUTPUT_DIR\"\n\n    # Build the xcodebuild command\n    local cmd=\"xcodebuild -create-xcframework\"\n\n    for target in \"${TARGETS[@]}\"; do\n        local lib_path=\"$SCRIPT_DIR/target/$target/release/$LIB_NAME\"\n        if [[ -f \"$lib_path\" ]]; then\n            # Strip the library for additional size reduction\n            log_info \"Stripping $target library...\"\n            strip -x \"$lib_path\" 2>/dev/null || true\n\n            cmd=\"$cmd -library $lib_path\"\n\n            # Add headers if they exist\n            local header_dir=\"$OUTPUT_DIR/include\"\n            if [[ -d \"$header_dir\" ]]; then\n                cmd=\"$cmd -headers $header_dir\"\n            fi\n        else\n            log_warn \"Skipping missing library: $lib_path\"\n        fi\n    done\n\n    cmd=\"$cmd -output $xcframework_path\"\n\n    log_info \"Running: $cmd\"\n    eval \"$cmd\"\n\n    if [[ -d \"$xcframework_path\" ]]; then\n        local size=$(du -sh \"$xcframework_path\" | cut -f1)\n        log_info \"Created $xcframework_path ($size)\"\n    else\n        log_error \"Failed to create xcframework\"\n        exit 1\n    fi\n}\n\n# Generate C header using cbindgen\ngenerate_header() {\n    log_info \"Generating C header...\"\n\n    local header_dir=\"$OUTPUT_DIR/include\"\n    local header_path=\"$header_dir/arti.h\"\n\n    mkdir -p \"$header_dir\"\n\n    # Create cbindgen.toml if it doesn't exist\n    if [[ ! -f \"$CRATE_NAME/cbindgen.toml\" ]]; then\n        cat > \"$CRATE_NAME/cbindgen.toml\" << 'EOF'\nlanguage = \"C\"\ninclude_guard = \"ARTI_H\"\nno_includes = true\nsys_includes = [\"stdint.h\", \"stdbool.h\"]\n\n[export]\ninclude = [\"arti_start\", \"arti_stop\", \"arti_is_running\", \"arti_bootstrap_progress\", \"arti_bootstrap_summary\", \"arti_go_dormant\", \"arti_wake\"]\n\n[fn]\nargs = \"Auto\"\n\n[parse]\nparse_deps = false\nEOF\n    fi\n\n    cbindgen --config \"$CRATE_NAME/cbindgen.toml\" \\\n             --crate \"$CRATE_NAME\" \\\n             --output \"$header_path\"\n\n    if [[ -f \"$header_path\" ]]; then\n        log_info \"Generated $header_path\"\n        cat \"$header_path\"\n    else\n        log_warn \"cbindgen did not generate header, creating manually...\"\n        # Fallback: create header manually\n        cat > \"$header_path\" << 'EOF'\n#ifndef ARTI_H\n#define ARTI_H\n\n#include <stdint.h>\n\n#ifdef __cplusplus\nextern \"C\" {\n#endif\n\n/**\n * Start Arti with a SOCKS5 proxy.\n *\n * @param data_dir Path to data directory for Tor state (C string)\n * @param socks_port Port for SOCKS5 proxy (e.g., 39050)\n * @return 0 on success, negative on error\n */\nint32_t arti_start(const char *data_dir, uint16_t socks_port);\n\n/**\n * Stop Arti gracefully.\n *\n * @return 0 on success, -1 if not running\n */\nint32_t arti_stop(void);\n\n/**\n * Check if Arti is currently running.\n *\n * @return 1 if running, 0 if not running\n */\nint32_t arti_is_running(void);\n\n/**\n * Get the current bootstrap progress (0-100).\n *\n * @return Progress percentage\n */\nint32_t arti_bootstrap_progress(void);\n\n/**\n * Get the current bootstrap summary string.\n *\n * @param buf Buffer to write the summary into\n * @param len Length of the buffer\n * @return Number of bytes written, -1 on error\n */\nint32_t arti_bootstrap_summary(char *buf, int32_t len);\n\n/**\n * Signal Arti to go dormant (reduce resource usage).\n *\n * @return 0 on success, -1 if not running\n */\nint32_t arti_go_dormant(void);\n\n/**\n * Signal Arti to wake from dormant mode.\n *\n * @return 0 on success, -1 if not running\n */\nint32_t arti_wake(void);\n\n#ifdef __cplusplus\n}\n#endif\n\n#endif /* ARTI_H */\nEOF\n        log_info \"Created manual header at $header_path\"\n    fi\n}\n\n# Print size report\nprint_size_report() {\n    log_info \"=== Size Report ===\"\n    for target in \"${TARGETS[@]}\"; do\n        local lib_path=\"$SCRIPT_DIR/target/$target/release/$LIB_NAME\"\n        if [[ -f \"$lib_path\" ]]; then\n            local size=$(du -h \"$lib_path\" | cut -f1)\n            echo \"  $target: $size\"\n        fi\n    done\n\n    local xcframework_path=\"$OUTPUT_DIR/$FRAMEWORK_NAME.xcframework\"\n    if [[ -d \"$xcframework_path\" ]]; then\n        local total_size=$(du -sh \"$xcframework_path\" | cut -f1)\n        echo \"  xcframework total: $total_size\"\n    fi\n}\n\n# Main\nmain() {\n    log_info \"Building arti-bitchat for iOS/macOS\"\n    log_info \"==================================\"\n\n    check_prerequisites\n    generate_header\n\n    for target in \"${TARGETS[@]}\"; do\n        build_target \"$target\"\n    done\n\n    create_xcframework\n    print_size_report\n\n    log_info \"Build complete!\"\n    log_info \"xcframework: $OUTPUT_DIR/$FRAMEWORK_NAME.xcframework\"\n}\n\n# Run\nmain \"$@\"\n"
  },
  {
    "path": "localPackages/BitLogger/Package.swift",
    "content": "// swift-tools-version: 5.9\n\nimport PackageDescription\n\nlet package = Package(\n    name: \"BitLogger\",\n    platforms: [\n        .iOS(.v16),\n        .macOS(.v13)\n    ],\n    products: [\n        .library(\n            name: \"BitLogger\",\n            targets: [\"BitLogger\"]\n        )\n    ],\n    targets: [\n        .target(\n            name: \"BitLogger\",\n            path: \"Sources\"\n        ),\n        .testTarget(\n            name: \"BitLoggerTests\",\n            dependencies: [\"BitLogger\"]\n        )\n    ]\n)\n"
  },
  {
    "path": "localPackages/BitLogger/Sources/OSLog+Categories.swift",
    "content": "//\n// OSLog+Categories.swift\n// BitLogger\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\n#if canImport(os.log)\nimport os.log\n#endif\n\npublic extension OSLog {\n    private static let subsystem = \"chat.bitchat\"\n\n    static let noise        = OSLog(subsystem: subsystem, category: \"noise\")\n    static let encryption   = OSLog(subsystem: subsystem, category: \"encryption\")\n    static let keychain     = OSLog(subsystem: subsystem, category: \"keychain\")\n    static let session      = OSLog(subsystem: subsystem, category: \"session\")\n    static let security     = OSLog(subsystem: subsystem, category: \"security\")\n    static let handshake    = OSLog(subsystem: subsystem, category: \"handshake\")\n    static let sync         = OSLog(subsystem: subsystem, category: \"sync\")\n}\n"
  },
  {
    "path": "localPackages/BitLogger/Sources/SecureLogger.swift",
    "content": "//\n// SecureLogger.swift\n// BitLogger\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n#if canImport(os.log)\nimport os.log\n#else\npublic struct OSLog {\n    public let subsystem: String\n    public let category: String\n\n    public init(subsystem: String, category: String) {\n        self.subsystem = subsystem\n        self.category = category\n    }\n}\n\npublic struct OSLogType: CustomStringConvertible {\n    private let label: String\n\n    private init(_ label: String) {\n        self.label = label\n    }\n\n    public var description: String { label }\n\n    public static let debug = OSLogType(\"debug\")\n    public static let info = OSLogType(\"info\")\n    public static let `default` = OSLogType(\"default\")\n    public static let error = OSLogType(\"error\")\n    public static let fault = OSLogType(\"fault\")\n}\n\n@usableFromInline\nlet secureLoggerFallbackFormatter: ISO8601DateFormatter = {\n    let formatter = ISO8601DateFormatter()\n    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]\n    return formatter\n}()\n\n@usableFromInline\nfunc os_log(_ message: StaticString, log: OSLog, type: OSLogType, _ args: CVarArg...) {\n    let rawFormat = String(describing: message)\n    let format = rawFormat\n        .replacingOccurrences(of: \"%{public}@\", with: \"%@\")\n        .replacingOccurrences(of: \"%{private}@\", with: \"%@\")\n    let formatted = String(format: format, arguments: args)\n    let timestamp = secureLoggerFallbackFormatter.string(from: Date())\n    print(\"[\\(timestamp)] [\\(log.subsystem)::\\(log.category)] [\\(type.description)] \\(formatted)\")\n}\n#endif\n\n/// Centralized security-aware logging framework\n/// Provides safe logging that filters sensitive data and security events\npublic final class SecureLogger {\n    \n    // MARK: - Timestamp Formatter\n    \n    private static let timestampFormatter: DateFormatter = {\n        let formatter = DateFormatter()\n        formatter.dateFormat = \"HH:mm:ss.SSS\"\n        formatter.timeZone = TimeZone.current\n        return formatter\n    }()\n    \n    // MARK: - Log Levels\n    \n    enum LogLevel {\n        case debug\n        case info\n        case warning\n        case error\n        case fault\n        \n        fileprivate var order: Int {\n            switch self {\n            case .debug: return 0\n            case .info: return 1\n            case .warning: return 2\n            case .error: return 3\n            case .fault: return 4\n            }\n        }\n        \n        var osLogType: OSLogType {\n            switch self {\n            case .debug: return .debug\n            case .info: return .info\n            case .warning: return .default\n            case .error: return .error\n            case .fault: return .fault\n            }\n        }\n    }\n\n    // MARK: - Global Threshold\n\n    /// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL.\n    private static let minimumLevel: LogLevel = {\n        let env = ProcessInfo.processInfo.environment[\"BITCHAT_LOG_LEVEL\"]?.lowercased()\n        switch env {\n        case \"debug\": return .debug\n        case \"warning\": return .warning\n        case \"error\": return .error\n        case \"fault\": return .fault\n        default: return .info\n        }\n    }()\n\n    private static func shouldLog(_ level: LogLevel) -> Bool {\n        return level.order >= minimumLevel.order\n    }\n}\n\n// MARK: - Public Logging Methods\n\npublic extension SecureLogger {\n    \n    static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,\n                      file: String = #file, line: Int = #line, function: String = #function) {\n        log(message(), category: category, level: .debug, file: file, line: line, function: function)\n    }\n    \n    static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,\n                     file: String = #file, line: Int = #line, function: String = #function) {\n        log(message(), category: category, level: .info, file: file, line: line, function: function)\n    }\n    \n    static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,\n                        file: String = #file, line: Int = #line, function: String = #function) {\n        log(message(), category: category, level: .warning, file: file, line: line, function: function)\n    }\n    \n    static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,\n                      file: String = #file, line: Int = #line, function: String = #function) {\n        log(message(), category: category, level: .error, file: file, line: line, function: function)\n    }\n    \n    /// Log errors with context\n    static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,\n                      file: String = #file, line: Int = #line, function: String = #function) {\n        let location = formatLocation(file: file, line: line, function: function)\n        let sanitized = context().sanitized()\n        let errorDesc = error.localizedDescription.sanitized()\n        \n        #if DEBUG\n        os_log(\"%{public}@ Error in %{public}@: %{public}@\", log: category, type: .error, location, sanitized, errorDesc)\n        #else\n        os_log(\"%{private}@ Error in %{private}@: %{private}@\", log: category, type: .error, location, sanitized, errorDesc)\n        #endif\n    }\n}\n\n// MARK: Security Event Logging\n\npublic extension SecureLogger {\n    \n    enum SecurityEvent {\n        case handshakeStarted(peerID: String)\n        case handshakeCompleted(peerID: String)\n        case handshakeFailed(peerID: String, error: String)\n        case sessionExpired(peerID: String)\n        case authenticationFailed(peerID: String)\n        \n        var message: String {\n            switch self {\n            case .handshakeStarted(let peerID):\n                return \"Handshake started with peer: \\(peerID.sanitized())\"\n            case .handshakeCompleted(let peerID):\n                return \"Handshake completed with peer: \\(peerID.sanitized())\"\n            case .handshakeFailed(let peerID, let error):\n                return \"Handshake failed with peer: \\(peerID.sanitized()), error: \\(error)\"\n            case .sessionExpired(let peerID):\n                return \"Session expired for peer: \\(peerID.sanitized())\"\n            case .authenticationFailed(let peerID):\n                return \"Authentication failed for peer: \\(peerID.sanitized())\"\n            }\n        }\n    }\n    \n    static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {\n        logSecurityEvent(event, level: .debug, file: file, line: line, function: function)\n    }\n    \n    static func info(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {\n        logSecurityEvent(event, level: .info, file: file, line: line, function: function)\n    }\n    \n    static func warning(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {\n        logSecurityEvent(event, level: .warning, file: file, line: line, function: function)\n    }\n    \n    static func error(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {\n        logSecurityEvent(event, level: .error, file: file, line: line, function: function)\n    }\n}\n\n// MARK: - Convenience Extensions\n\npublic extension SecureLogger {\n    \n    enum KeyOperation: String, CustomStringConvertible {\n        case load\n        case create\n        case generate\n        case delete\n        case save\n        \n        public var description: String { rawValue }\n    }\n    \n    /// Log key management operations\n    static func logKeyOperation(_ operation: KeyOperation, keyType: String, success: Bool = true,\n                                file: String = #file, line: Int = #line, function: String = #function) {\n        if success {\n            debug(\"Key operation '\\(operation)' for \\(keyType) succeeded\", category: .keychain, file: file, line: line, function: function)\n        } else {\n            error(\"Key operation '\\(operation)' for \\(keyType) failed\", category: .keychain, file: file, line: line, function: function)\n        }\n    }\n}\n\n// MARK: - Private Helpers\n\nprivate extension SecureLogger {\n    /// Log general messages with automatic sensitive data filtering\n    static func log(_ message: @autoclosure () -> String, category: OSLog, level: LogLevel,\n                    file: String, line: Int, function: String) {\n        guard shouldLog(level) else { return }\n        let location = formatLocation(file: file, line: line, function: function)\n        let sanitized = \"\\(location) \\(message())\".sanitized()\n        \n        #if DEBUG\n        os_log(\"%{public}@\", log: category, type: level.osLogType, sanitized)\n        #else\n        // In release builds, only log non-debug messages\n        if level != .debug {\n            os_log(\"%{private}@\", log: category, type: level.osLogType, sanitized)\n        }\n        #endif\n    }\n    \n    /// Log a security event\n    static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,\n                                 file: String, line: Int, function: String) {\n        guard shouldLog(level) else { return }\n        let location = formatLocation(file: file, line: line, function: function)\n        let message = \"\\(location) \\(event.message)\"\n        \n        #if DEBUG\n        os_log(\"%{public}@\", log: .security, type: level.osLogType, message)\n        #else\n        // In release, use private logging to prevent sensitive data exposure\n        os_log(\"%{private}@\", log: .security, type: level.osLogType, message)\n        #endif\n    }\n    \n    /// Format location information for logging\n    static func formatLocation(file: String, line: Int, function: String) -> String {\n        let fileName = (file as NSString).lastPathComponent\n        let timestamp = timestampFormatter.string(from: Date())\n        return \"[\\(timestamp)] [\\(fileName):\\(line) \\(function)]\"\n    }\n}\n\n// MARK: - Migration Helper\n\n/// Helper to migrate from print statements to SecureLogger\n/// Usage: Replace print(...) with secureLog(...)\npublic func secureLog(_ items: Any..., separator: String = \" \", terminator: String = \"\\n\",\n                      file: String = #file, line: Int = #line, function: String = #function) {\n    #if DEBUG\n    let message = items.map { String(describing: $0) }.joined(separator: separator)\n    SecureLogger.debug(message, file: file, line: line, function: function)\n    #endif\n}\n"
  },
  {
    "path": "localPackages/BitLogger/Sources/String+Sanitization.swift",
    "content": "//\n// String+Sanitization.swift\n// BitLogger\n//\n// This is free and unencumbered software released into the public domain.\n// For more information, see <https://unlicense.org>\n//\n\nimport Foundation\n\nextension String {\n    /// Sanitize strings to remove potentially sensitive data\n    func sanitized() -> String {\n        let key = self as NSString\n        \n        // Check cache first\n        if let cached = Self.queue.sync(execute: { Self.cache.object(forKey: key) }) {\n            return cached as String\n        }\n        \n        var sanitized = self\n        \n        // Remove full fingerprints (keep first 8 chars for debugging)\n        let fingerprintPattern = #/[a-fA-F0-9]{64}/#\n        sanitized = sanitized.replacing(fingerprintPattern) { match in\n            let fingerprint = String(match.output)\n            return String(fingerprint.prefix(8)) + \"...\"\n        }\n        \n        // Remove base64 encoded data that might be keys\n        let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/#\n        sanitized = sanitized.replacing(base64Pattern) { _ in\n            \"<base64-data>\"\n        }\n        \n        // Remove potential passwords (assuming they're in quotes or after \"password:\")\n        let passwordPattern = #/password[\"\\s:=]+[\"']?[^\"'\\s]+[\"']?/#\n        sanitized = sanitized.replacing(passwordPattern) { _ in\n            \"password: <redacted>\"\n        }\n        \n        // Truncate peer IDs to first 8 characters\n        let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/#\n        sanitized = sanitized.replacing(peerIDPattern) { match in\n            \"peerID: \\(match.1)...\"\n        }\n        \n        // Cache the result\n        Self.queue.sync {\n            Self.cache.setObject(sanitized as NSString, forKey: key)\n        }\n        \n        return sanitized\n    }\n}\n\n// MARK: - Cache Helpers\n\nprivate extension String {\n    static let queue = DispatchQueue(label: \"chat.bitchat.securelogger.cache\", attributes: .concurrent)\n\n    static let cache: NSCache<NSString, NSString> = {\n        let cache = NSCache<NSString, NSString>()\n        cache.countLimit = 100 // Keep last 100 sanitized strings\n        return cache\n    }()\n}\n"
  },
  {
    "path": "localPackages/BitLogger/Tests/StringSanitizationTests.swift",
    "content": "//\n//  StringSanitizationTests.swift\n//  BitLogger\n//\n//  Created by Islam on 19/10/2025.\n//\n\nimport Testing\n@testable import BitLogger\n\nstruct StringSanitizationTests {\n    \n    @Test(\"64-hex fingerprint is truncated to first 8 chars followed by ellipsis\")\n    func fingerprintTruncation() async throws {\n        let fingerprint = \"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"\n        #expect(fingerprint.count == 64)\n        \n        let input = \"fingerprint=\\(fingerprint)\"\n        let output = input.sanitized()\n        \n        #expect(output.contains(\"fingerprint=01234567...\"))\n        // Ensure no full fingerprint remains\n        #expect(output.contains(fingerprint) == false)\n    }\n    \n    @Test(\"Multiple fingerprints in a string are all truncated\")\n    func multipleFingerprintTruncation() async throws {\n        let fp1 = String(repeating: \"a\", count: 64)\n        let fp2 = String(repeating: \"b\", count: 64)\n        let input = \"fp1=\\(fp1) fp2=\\(fp2)\"\n        let output = input.sanitized()\n        #expect(output.contains(\"fp1=aaaaaaaa...\"))\n        #expect(output.contains(\"fp2=bbbbbbbb...\"))\n        #expect(output.contains(fp1) == false)\n        #expect(output.contains(fp2) == false)\n    }\n    \n    @Test(\"Base64-like long data is replaced with <base64-data>\")\n    func base64Replacement() async throws {\n        // 44+ chars of base64 characters\n        let base64ish = \"QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2NTQzMjE=\"\n        let input = \"payload=\\(base64ish)\"\n        let output = input.sanitized()\n        #expect(output == \"payload=<base64-data>\")\n    }\n    \n    @Test(\"Base64-like without padding is replaced with <base64-data>\")\n    func base64NoPaddingReplacement() async throws {\n        let base64ish = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\"\n        #expect(base64ish.count >= 40)\n        let input = \"b64:\\(base64ish)\"\n        let output = input.sanitized()\n        #expect(output == \"b64:<base64-data>\")\n    }\n    \n    @Test(\"Short base64-like strings (below threshold) are not replaced\")\n    func shortBase64NotReplaced() async throws {\n        let short = \"QUJDREVGR0hJSktMTU5P\" // < 40 chars\n        let input = \"payload=\\(short)\"\n        let output = input.sanitized()\n        #expect(output == input)\n    }\n    \n    @Test(\"Password redaction for key:value formats\", arguments: [\n        \"password: secret123\",\n        \"password=secret123\",\n        \"password = secret123\",\n        \"password: 'secret123'\",\n        \"password:\\\"secret123\\\"\",\n        \"password='secret123'\"\n    ])\n    func passwordRedactionKeyValue(password: String) async throws {\n        #expect(password.sanitized() == \"password: <redacted>\")\n    }\n    \n    @Test(\"Password redaction inside wider messages\")\n    func passwordRedactionInContext() async throws {\n        let input = \"user=john password: 'p@ssW0rd' attempt=1\"\n        let output = input.sanitized()\n        #expect(output == \"user=john password: <redacted> attempt=1\")\n    }\n    \n    @Test(\"PeerID is truncated to first 8 chars followed by ellipsis\")\n    func peerIDTruncation() async throws {\n        let peer = \"ABCDEF12GHIJKL34\"\n        let input = \"peerID: \\(peer)\"\n        let output = input.sanitized()\n        #expect(output == \"peerID: ABCDEF12...\")\n    }\n    \n    @Test(\"PeerID not truncated when exactly 8 chars\")\n    func peerIDExactlyEightNotTruncated() async throws {\n        let peer = \"ABCDEF12\"\n        let input = \"peerID: \\(peer)\"\n        let output = input.sanitized()\n        // Pattern only matches when there are more than 8 trailing chars, so unchanged\n        #expect(output == input)\n    }\n    \n    @Test(\"Non-matching content remains unchanged\")\n    func nonMatchingUnchanged() async throws {\n        let input = \"Hello world 123 - nothing sensitive here.\"\n        let output = input.sanitized()\n        #expect(output == input)\n    }\n    \n    @Test(\"Idempotency: sanitizing twice yields same result\")\n    func idempotentSanitization() async throws {\n        let input = \"\"\"\n        fingerprint=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \\\n        password: \"superSecret\" \\\n        peerID: ZYXWVUT987654321 \\\n        payload=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\n        \"\"\"\n        let once = input.sanitized()\n        let twice = once.sanitized()\n        #expect(once == twice)\n    }\n    \n    @Test(\"Mixed content: all rules apply in a single string\")\n    func mixedContent() async throws {\n        let fingerprint = \"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\"\n        let base64ish = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\"\n        let peer = \"PEERID01EXTRA\"\n        let input = \"fp=\\(fingerprint) password='x' peerID: \\(peer) data=\\(base64ish)\"\n        let output = input.sanitized()\n        #expect(output.contains(\"fp=fedcba98...\"))\n        #expect(output.contains(\"password: <redacted>\"))\n        #expect(output.contains(\"peerID: PEERID01...\"))\n        #expect(output.contains(\"data=<base64-data>\"))\n        #expect(output.contains(fingerprint) == false)\n        #expect(output.contains(base64ish) == false)\n    }\n    \n    @Test(\"Cache returns consistent result for repeated inputs\")\n    func cacheHitConsistency() async throws {\n        let input = \"password: hunter2\"\n        let first = input.sanitized()\n        let second = input.sanitized()\n        #expect(first == \"password: <redacted>\")\n        #expect(first == second)\n    }\n}\n"
  },
  {
    "path": "relays/online_relays_gps.csv",
    "content": "Relay URL,Latitude,Longitude\r\nrelay-rpi.edufeed.org,49.4521,11.0767\r\ncanteen-hankering.space,40.8302,-74.1299\r\ninbox.scuba323.com,40.8218,-74.45\r\ntenex.chat,50.4754,12.3683\r\nnostr.blankfors.se,60.1699,24.9384\r\nrelay.homeinhk.xyz,45.5152,-122.678\r\nsoloco.nl,43.6532,-79.3832\r\nnostr.okienko.live,50.1109,8.68213\r\nrelay.olas.app,50.4754,12.3683\r\npremium.primal.net,43.6532,-79.3832\r\nnostr.overmind.lol,43.6532,-79.3832\r\nnostr.carroarmato0.be,51.0368,3.21186\r\nnostr.islandarea.net,35.4669,-97.6473\r\nrelay.routstr.com,43.6532,-79.3832\r\nrelay.nostraddress.com,43.6532,-79.3832\r\nrelay.og.coop,43.6532,-79.3832\r\nnostr.robosats.org,64.1476,-21.9392\r\nnostr-relay-1.trustlessenterprise.com,43.6532,-79.3832\r\nnostr2.girino.org,43.6532,-79.3832\r\nrelay.primal.net,43.6532,-79.3832\r\nsanto.iguanatech.net,40.8302,-74.1299\r\nnostr.tadryanom.me,43.6532,-79.3832\r\nnostr.mifen.me,43.6532,-79.3832\r\nrelay.cyphernomad.com,60.4032,25.0321\r\nrelay.nostr.place,32.7767,-96.797\r\ntestr.nymble.world,40.8054,-74.0241\r\nwot.nostr.place,32.7767,-96.797\r\nstrfry.atlantislabs.space,43.6532,-79.3832\r\nrelay.flashapp.me,43.652,-79.3633\r\nnpub1spxdug4m3y24hpx5crm0el4zhkk0wafs8kp6m0xu0wecygqej2xqq8gyhx.fips.network,43.6532,-79.3832\r\nnostr-relay.cbrx.io,43.6532,-79.3832\r\nnostr.nodesmap.com,59.3327,18.0656\r\nrelay.bithome.site,52.3563,4.95714\r\nnostr.girino.org,43.6532,-79.3832\r\nnostr-03.dorafactory.org,1.35208,103.82\r\nnostrcheck.tnsor.network,43.6532,-79.3832\r\nstrfry.apps3.slidestr.net,40.4167,-3.70329\r\nrelay.bitcoindistrict.org,43.6532,-79.3832\r\nplebchain.club,43.6532,-79.3832\r\nsrtrelay.c-stellar.net,43.6532,-79.3832\r\nnostr-relay.nextblockvending.com,47.2343,-119.853\r\nnostr-relay.corb.net,38.8353,-104.822\r\nr.0kb.io,32.789,-96.7989\r\nrelay.malxte.de,52.52,13.405\r\nrelay.nostrzh.org,43.6532,-79.3832\r\nrelay.xavierdamman.com,49.4543,11.0746\r\nrelay.anmore.me,49.281,-123.117\r\nrelay.vantis.ninja,43.6532,-79.3832\r\nrelay-dev.satlantis.io,40.8302,-74.1299\r\nrelay.snort.social,53.3498,-6.26031\r\nrelay.gulugulu.moe,43.6532,-79.3832\r\nrelay.wavlake.com,41.2619,-95.8608\r\ntestrelay.era21.space,43.6532,-79.3832\r\nrelay.wavefunc.live,39.7392,-104.99\r\nnostr-kyomu-haskell.onrender.com,37.7775,-122.397\r\nrelay.bornheimer.app,50.1109,8.68213\r\nrelayone.soundhsa.com,39.1008,-94.5811\r\nrelay.nostrcheck.me,43.6532,-79.3832\r\nnostr.azzamo.net,52.2633,21.0283\r\nrelay.zone667.com,60.1699,24.9384\r\nslick.mjex.me,39.0418,-77.4744\r\nnostr-relay.zeabur.app,25.0797,121.234\r\nrelay.opensourcevillage.org,49.4543,11.0746\r\nrelay.bao.network,49.4543,11.0746\r\npyramid.cult.cash,32.9483,-96.7299\r\nnostr.oxtr.dev,50.4754,12.3683\r\nkotukonostr.onrender.com,37.7775,-122.397\r\nrelayrs.notoshi.win,43.6532,-79.3832\r\nnostr.stakey.net,52.3676,4.90414\r\nrelay.0xchat.com,43.6532,-79.3832\r\nnostr.luisschwab.net,43.6532,-79.3832\r\nrelay.lacompagniemaximus.com,45.3147,-73.8785\r\npurplerelay.com,43.6532,-79.3832\r\nrelay.btcforplebs.com,43.6532,-79.3832\r\nnostrelites.org,41.8781,-87.6298\r\nrelay.upleb.uk,51.9194,19.1451\r\nrelay.libernet.app,43.6532,-79.3832\r\nrelay.mostro.network,40.8302,-74.1299\r\nstrfry.elswa-dev.online,50.1109,8.68213\r\nrelay.agorist.space,52.3734,4.89406\r\nnostr.notribe.net,40.8302,-74.1299\r\nrelay-freeharmonypeople.space,38.7223,-9.13934\r\nnostr.thalheim.io,60.1699,24.9384\r\nrelay.erybody.com,41.4513,-81.7021\r\nrelay.layer.systems,49.0291,8.35695\r\nchat-relay.zap-work.com,43.6532,-79.3832\r\nnostr.noones.com,50.1109,8.68213\r\nrelay.orangepill.ovh,49.1689,-0.358841\r\nnostr.defucc.me,50.1109,8.68213\r\nnostrelay.circum.space,52.3676,4.90414\r\npublic.crostr.com,43.6532,-79.3832\r\nrelay.openfarmtools.org,60.1699,24.9384\r\noffchain.pub,39.1585,-94.5728\r\nnestr.nedao.ch,47.0151,6.98832\r\nrelay.evanverma.com,40.8302,-74.1299\r\ntestnet-relay.samt.st,40.8302,-74.1299\r\nrelay-nl.zombi.cloudrodion.com,50.8943,6.06237\r\nnostr-relay.xbytez.io,50.6924,3.20113\r\nnostr-pub.wellorder.net,45.5201,-122.99\r\nstrfry.shock.network,39.0438,-77.4874\r\nrelay-testnet.k8s.layer3.news,37.3387,-121.885\r\nnostr.chrissexton.org,43.6532,-79.3832\r\nadre.su,59.9311,30.3609\r\nnostr.jerrynya.fun,31.2304,121.474\r\nnrs-01.darkcloudarcade.com,39.1008,-94.5811\r\nnos.lol,50.4754,12.3683\r\nrelay.nostr.wirednet.jp,34.706,135.493\r\nrelay.agora.social,50.7383,15.0648\r\nr.bitcoinhold.net,43.6532,-79.3832\r\nwot.codingarena.top,50.4754,12.3683\r\npurpura.cloud,43.6532,-79.3832\r\nrelay02.lnfi.network,35.6764,139.65\r\nrelay.nostriot.com,41.5695,-83.9786\r\nnostr.spaceshell.xyz,43.6532,-79.3832\r\nfenrir-s.notoshi.win,43.6532,-79.3832\r\nx.kojira.io,43.6532,-79.3832\r\nrelay.nostrdice.com,-33.8688,151.209\r\nnostr.nadajnik.org,50.1109,8.68213\r\nfreelay.sovbit.host,64.1476,-21.9392\r\nnostr.spicyz.io,43.6532,-79.3832\r\nbucket.coracle.social,37.7775,-122.397\r\nrelay.fundstr.me,42.3601,-71.0589\r\nnostr.wom.wtf,43.6532,-79.3832\r\nrelay.henryxplace.eu.org:9988,31.2304,121.474\r\ntaxation-capable-cards-takes.trycloudflare.com,43.6532,-79.3832\r\nrelay.mmwaves.de,48.8575,2.35138\r\nrelay.guggero.org,46.0037,8.95105\r\nrelay5.bitransfer.org,43.6532,-79.3832\r\nnostrue.com,40.8054,-74.0241\r\nnostr.night7.space,50.4754,12.3683\r\nrelay.endfiat.money,43.6532,-79.3832\r\nnostr.0cx.de,49.029,8.35695\r\ntemp.iris.to,43.6532,-79.3832\r\nrelay.internationalright-wing.org,-22.5022,-48.7114\r\nrelay.staging.commonshub.brussels,49.4543,11.0746\r\nrelay.bitmacro.io,48.8566,2.35222\r\nnostr.rblb.it,43.6532,-79.3832\r\ndiscovery.us.nostria.app,52.3676,4.90414\r\nrelay.binaryrobot.com,43.6532,-79.3832\r\nrelay.islandbitcoin.com,12.8498,77.6545\r\nrelay-fra.zombi.cloudrodion.com,48.8566,2.35222\r\nrelay.nostrverse.net,43.6532,-79.3832\r\nrelay.snotr.nl:49999,52.0195,4.42946\r\nschnorr.me,43.6532,-79.3832\r\nprl.plus,55.7628,37.5983\r\nrelay.wayback.st,52.3676,4.90414\r\n0x-nostr-relay.fly.dev,48.8575,2.35138\r\nrelay.vrtmrz.net,43.6532,-79.3832\r\nnostr.rtvslawenia.com,49.4543,11.0746\r\nrelay.bitmacro.cloud,43.6532,-79.3832\r\nrelay2.ngengine.org,43.6532,-79.3832\r\nnostr.4rs.nl,49.0291,8.35696\r\nnostr.self-determined.de,53.5,10.25\r\nrelay.decentnewsroom.com,50.4754,12.3683\r\nnostr.faultables.net,43.6532,-79.3832\r\nbcast.seutoba.com.br,43.6532,-79.3832\r\nrelay.credenso.cafe,43.3601,-80.3127\r\nribo.us.nostria.app,41.5868,-93.625\r\ntop.testrelay.top,43.6532,-79.3832\r\nwot.shaving.kiwi,43.6532,-79.3832\r\nnostr.liberty.fans,36.9104,-89.5875\r\norly.musiquay.org,43.6532,-79.3832\r\nrelay.jeffg.fyi,43.6532,-79.3832\r\nrelay.ditto.pub,43.6532,-79.3832\r\nnostr.bitcoiner.social,47.6743,-117.112\r\nnostrcity-club.fly.dev,48.8575,2.35138\r\nnostr.dpinkerton.com,39.1008,-94.5811\r\nrelay.dwadziesciajeden.pl,52.2297,21.0122\r\nnostr-verified.wellorder.net,45.5201,-122.99\r\nkanagrovv-pyramid.kozow.com,43.4305,-83.9638\r\nnostr.na.social,43.6532,-79.3832\r\ntheoutpost.life,64.1476,-21.9392\r\nrelay.wellorder.net,45.5201,-122.99\r\napi.freefrom.space/v1/ws,43.6532,-79.3832\r\nrelay.nostr-check.me,43.6532,-79.3832\r\nv-relay.d02.vrtmrz.net,34.6937,135.502\r\nrelay.puresignal.news,43.6532,-79.3832\r\nrelay.satlantis.io,32.8769,-80.0114\r\nbitcoiner.social,47.6743,-117.112\r\nrelay2.angor.io,48.1046,11.6002\r\nrelay-dev.gulugulu.moe,43.6532,-79.3832\r\nwot.sudocarlos.com,43.6532,-79.3832\r\nrelay.angor.io,48.1046,11.6002\r\narticles.layer3.news,37.3387,-121.885\r\ninbox.azzamo.net,45.3147,-73.8785\r\nnostr.agentcampfire.com,52.3676,4.90414\r\nnostr.myshosholoza.co.za,52.3913,4.66545\r\nmyvoiceourstory.org,37.3598,-121.981\r\nrelay.seq1.net,43.6532,-79.3832\r\nrelay.minibolt.info,43.6532,-79.3832\r\nnostr.mom,50.4754,12.3683\r\nrelay.bullishbounty.com,43.6532,-79.3832\r\nrelay.bnos.space,43.6532,-79.3832\r\nnostr.2b9t.xyz,34.0549,-118.243\r\npyramid.aaro.cc,34.0549,-118.243\r\nstrfry.ymir.cloud,34.0965,-117.585\r\nnostr.quali.chat,60.1699,24.9384\r\nrelay.bitmacro.pro,43.6532,-79.3832\r\ndiscovery.eu.nostria.app,52.3676,4.90414\r\nyabu.me,35.6092,139.73\r\nnostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832\r\nrelay.nostu.be,40.4167,-3.70329\r\nnostr.zoracle.org,45.6018,-121.185\r\nribo.eu.nostria.app,52.3676,4.90414\r\nrelay.nostrhub.fr,48.1045,11.6004\r\nnostr.data.haus,50.4754,12.3683\r\nrelay.spacetomatoes.net,42.3601,-71.0589\r\nrelay.satnam.pub,43.6532,-79.3832\r\naaa-api.freefrom.space/v1/ws,43.6532,-79.3832\r\nnostr.ps1829.com,33.8851,130.883\r\nnostr.vulpem.com,49.4543,11.0746\r\nrelay.ngengine.org,43.6532,-79.3832\r\nnostr.snowbla.de,60.1699,24.9384\r\nnostr.bitczat.pl,60.1699,24.9384\r\nrelay.lightning.pub,39.0438,-77.4874\r\nbitchat.nostr1.com,38.6327,-90.1961\r\nnostr.chaima.info,50.1109,8.68213\r\nstrfry.openhoofd.nl,51.9229,4.40833\r\nespelho.girino.org,43.6532,-79.3832\r\nbcast.girino.org,43.6532,-79.3832\r\nrelay.nostr.net,43.6532,-79.3832\r\nr.alphaama.com,60.1699,24.9384\r\nrelay.lanacoin-eternity.com,40.8302,-74.1299\r\nrelay.satmaxt.xyz,43.6532,-79.3832\r\nrelayone.geektank.ai,39.1008,-94.5811\r\nnostr.ovia.to,43.6532,-79.3832\r\nsatsage.xyz,37.3986,-121.964\r\nrelay.cathouse-propeller.com,52.3676,4.90414\r\nnostr-01.yakihonne.com,1.29524,103.79\r\nlightning.red,53.3498,-6.26031\r\nrelay.ru.ac.th,13.7607,100.627\r\nrelay.qstr.app,51.5072,-0.127586\r\nrelay.mitchelltribe.com,39.0438,-77.4874\r\nrelay.purplefrog.cloud,35.6916,139.768\r\nrelay.commonshub.brussels,49.4543,11.0746\r\nnostr-relay.psfoundation.info,39.0438,-77.4874\r\nrelay.laantungir.net,-19.4692,-42.5315\r\nlibrerelay.aaroniumii.com,43.6532,-79.3832\r\nrelay.jabato.space,52.6907,4.8181\r\nokn.czas.plus,50.1109,8.68213\r\nrelay.fountain.fm,43.6532,-79.3832\r\nwot.brightbolt.net,47.6732,-117.239\r\nkhatru.nostrver.se,51.1792,5.89444\r\nnostr-relay.zimage.com,34.0549,-118.243\r\nspookstr2.nostr1.com,38.6327,-90.1961\r\nnostr.simplex.icu,50.1109,8.68213\r\nrelay.getsafebox.app,43.6532,-79.3832\r\nnostr.mikoshi.de,51.2821,6.78285\r\nnostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874\r\nrelay.klabo.world,47.674,-122.122\r\nwot.dtonon.com,43.6532,-79.3832\r\nnostr.n7ekb.net,36.1527,-95.9902\r\nrelay.edufeed.org,49.4521,11.0767\r\nrelay.samt.st,40.8302,-74.1299\r\nrelay.chorus.community,48.5333,10.7\r\nnostr-relay.online,43.6532,-79.3832\r\nnostr.red5d.dev,43.6532,-79.3832\r\nrelay.nosto.re,51.1792,5.89444\r\nvault.iris.to,43.6532,-79.3832\r\nnostr-2.21crypto.ch,47.5356,8.73209\r\nnostr.sathoarder.com,48.5734,7.75211\r\nrelay.damus.io,43.6532,-79.3832\r\nnostr-relay.amethyst.name,39.0067,-77.4291\r\nrelay.illuminodes.com,47.6062,-122.332\r\nmemlay.v0l.io,53.3498,-6.26031\r\ntestorly.nosfabrica.com,37.3986,-121.964\r\nrelay.toastr.net,40.8054,-74.0241\r\nwot.nostr.net,43.6532,-79.3832\r\nrelay.contextvm.org,53.3498,-6.26031\r\nnostr.huszonegy.world,47.4979,19.0402\r\nrelay.redsh1ft.com,33.6129,-111.915\r\nrelay.sigit.io,50.4754,12.3683\r\nnostr-dev.wellorder.net,45.5201,-122.99\r\nrelay.mostr.pub,43.6532,-79.3832\r\nrelay.sharegap.net,43.6532,-79.3832\r\nwot.nostr.party,36.1627,-86.7816\r\nstrfry.bonsai.com,37.8716,-122.273\r\nrelay.visionfusen.org,43.6532,-79.3832\r\ntreuzkas.branruz.com,48.8575,2.35138\r\nrelay.degmods.com,50.4754,12.3683\r\nrelay.cypherflow.ai,48.8575,2.35138\r\npyramid.self-determined.de,53.5,10.25\r\nrelay.cosmicbolt.net,37.3986,-121.964\r\nfanfares.nostr1.com,38.6327,-90.1961\r\norly-relay.imwald.eu,48.8575,2.35138\r\n"
  }
]